diff --git a/core/src/main/java/google/registry/persistence/GenericCollectionUserType.java b/core/src/main/java/google/registry/persistence/GenericCollectionUserType.java index 8ced7615a..62e3fd7e4 100644 --- a/core/src/main/java/google/registry/persistence/GenericCollectionUserType.java +++ b/core/src/main/java/google/registry/persistence/GenericCollectionUserType.java @@ -14,20 +14,17 @@ package google.registry.persistence; -import java.io.Serializable; import java.sql.Array; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Types; import java.util.Collection; -import java.util.Objects; import org.hibernate.HibernateException; import org.hibernate.engine.spi.SharedSessionContractImplementor; -import org.hibernate.usertype.UserType; /** Generic Hibernate user type to store/retrieve Java collection as an array in Cloud SQL. */ -public abstract class GenericCollectionUserType implements UserType { +public abstract class GenericCollectionUserType extends MutableUserType { abstract T getNewCollection(); @@ -62,16 +59,6 @@ public abstract class GenericCollectionUserType implements return new int[] {getColumnType().getTypeCode()}; } - @Override - public boolean equals(Object x, Object y) throws HibernateException { - return Objects.equals(x, y); - } - - @Override - public int hashCode(Object x) throws HibernateException { - return x == null ? 0 : x.hashCode(); - } - @Override public Object nullSafeGet( ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner) @@ -98,30 +85,4 @@ public abstract class GenericCollectionUserType implements Array arr = st.getConnection().createArrayOf(getColumnType().getTypeName(), list.toArray()); st.setArray(index, arr); } - - // TODO(b/147489651): Investigate how to properly implement the below methods. - @Override - public Object deepCopy(Object value) throws HibernateException { - return value; - } - - @Override - public boolean isMutable() { - return false; - } - - @Override - public Serializable disassemble(Object value) throws HibernateException { - return (Serializable) value; - } - - @Override - public Object assemble(Serializable cached, Object owner) throws HibernateException { - return cached; - } - - @Override - public Object replace(Object original, Object target, Object owner) throws HibernateException { - return original; - } } diff --git a/core/src/main/java/google/registry/persistence/MapUserType.java b/core/src/main/java/google/registry/persistence/MapUserType.java new file mode 100644 index 000000000..6ec284d5e --- /dev/null +++ b/core/src/main/java/google/registry/persistence/MapUserType.java @@ -0,0 +1,57 @@ +// Copyright 2020 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 java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Types; +import java.util.Map; +import org.hibernate.HibernateException; +import org.hibernate.engine.spi.SharedSessionContractImplementor; +import org.hibernate.usertype.UserType; + +/** + * A custom {@link UserType} used to convert a Java {@link Map} to/from PostgreSQL + * hstore type. Per this doc, as + * hstore keys and values are simply text strings, the type of key and value in the Java map has to + * be {@link String} as well. + */ +public class MapUserType extends MutableUserType { + + @Override + public int[] sqlTypes() { + return new int[] {Types.OTHER}; + } + + @Override + public Class returnedClass() { + return Map.class; + } + + @Override + public Object nullSafeGet( + ResultSet rs, String[] names, SharedSessionContractImplementor session, Object owner) + throws HibernateException, SQLException { + return rs.getObject(names[0]); + } + + @Override + public void nullSafeSet( + PreparedStatement st, Object value, int index, SharedSessionContractImplementor session) + throws HibernateException, SQLException { + st.setObject(index, value); + } +} diff --git a/core/src/main/java/google/registry/persistence/MutableUserType.java b/core/src/main/java/google/registry/persistence/MutableUserType.java new file mode 100644 index 000000000..534020646 --- /dev/null +++ b/core/src/main/java/google/registry/persistence/MutableUserType.java @@ -0,0 +1,63 @@ +// Copyright 2020 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 java.io.Serializable; +import java.util.Objects; +import org.hibernate.HibernateException; +import org.hibernate.usertype.UserType; + +/** + * An abstract class represents a mutable Hibernate user type which implements related methods + * defined in {@link UserType}. + */ +public abstract class MutableUserType implements UserType { + + @Override + public boolean equals(Object x, Object y) throws HibernateException { + return Objects.equals(x, y); + } + + @Override + public int hashCode(Object x) throws HibernateException { + return x == null ? 0 : x.hashCode(); + } + + // TODO(b/147489651): Investigate how to properly implement the below methods. + @Override + public Object deepCopy(Object value) throws HibernateException { + return value; + } + + @Override + public boolean isMutable() { + return false; + } + + @Override + public Serializable disassemble(Object value) throws HibernateException { + return (Serializable) value; + } + + @Override + public Object assemble(Serializable cached, Object owner) throws HibernateException { + return cached; + } + + @Override + public Object replace(Object original, Object target, Object owner) throws HibernateException { + return original; + } +} diff --git a/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java b/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java index 3766363a5..758175adb 100644 --- a/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java +++ b/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java @@ -24,6 +24,7 @@ public class NomulusPostgreSQLDialect extends PostgreSQL95Dialect { registerColumnType(Types.VARCHAR, "text"); registerColumnType(Types.TIMESTAMP_WITH_TIMEZONE, "timestamptz"); registerColumnType(Types.TIMESTAMP, "timestamptz"); + registerColumnType(Types.OTHER, "hstore"); for (ArrayColumnType arrayType : ArrayColumnType.values()) { registerColumnType(arrayType.getTypeCode(), arrayType.getTypeDdlName()); } diff --git a/core/src/test/java/google/registry/persistence/MapUserTypeTest.java b/core/src/test/java/google/registry/persistence/MapUserTypeTest.java new file mode 100644 index 000000000..351341d5b --- /dev/null +++ b/core/src/test/java/google/registry/persistence/MapUserTypeTest.java @@ -0,0 +1,140 @@ +// Copyright 2020 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.truth.Truth.assertThat; +import static google.registry.model.transaction.TransactionManagerFactory.jpaTm; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ImmutableMap; +import google.registry.model.ImmutableObject; +import google.registry.model.transaction.JpaTestRules; +import google.registry.model.transaction.JpaTestRules.JpaIntegrationTestRule; +import java.util.Map; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.NoResultException; +import org.hibernate.annotations.Type; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +/** Unit tests for {@link MapUserType}. */ +@RunWith(JUnit4.class) +public class MapUserTypeTest { + + // Note that JpaIntegrationTestRule is used here as the hstore extension is installed + // when nomulus.golden.sql is executed as the init script. + @Rule + public final JpaIntegrationTestRule jpaRule = + new JpaTestRules.Builder().withEntityClass(TestEntity.class).buildIntegrationTestRule(); + + @Test + public void roundTripConversion_returnsSameMap() { + Map map = ImmutableMap.of("key1", "value1", "key2", "value2"); + TestEntity testEntity = new TestEntity(map); + jpaTm().transact(() -> jpaTm().getEntityManager().persist(testEntity)); + TestEntity persisted = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + assertThat(persisted.map).containsExactly("key1", "value1", "key2", "value2"); + } + + @Test + public void testMerge_succeeds() { + Map map = ImmutableMap.of("key1", "value1", "key2", "value2"); + TestEntity testEntity = new TestEntity(map); + jpaTm().transact(() -> jpaTm().getEntityManager().persist(testEntity)); + TestEntity persisted = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + persisted.map = ImmutableMap.of("key3", "value3"); + jpaTm().transact(() -> jpaTm().getEntityManager().merge(persisted)); + TestEntity updated = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + assertThat(updated.map).containsExactly("key3", "value3"); + } + + @Test + public void testNullValue_writesAndReadsNullSuccessfully() { + TestEntity testEntity = new TestEntity(null); + jpaTm().transact(() -> jpaTm().getEntityManager().persist(testEntity)); + TestEntity persisted = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + assertThat(persisted.map).isNull(); + } + + @Test + public void testEmptyCollection_writesAndReadsEmptyCollectionSuccessfully() { + TestEntity testEntity = new TestEntity(ImmutableMap.of()); + jpaTm().transact(() -> jpaTm().getEntityManager().persist(testEntity)); + TestEntity persisted = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + assertThat(persisted.map).isEmpty(); + } + + @Test + public void testNativeQuery_succeeds() throws Exception { + executeNativeQuery( + "INSERT INTO \"TestEntity\" (name, map) VALUES ('id', 'key1=>value1, key2=>value2')"); + + assertThat( + getSingleResultFromNativeQuery( + "SELECT map -> 'key1' FROM \"TestEntity\" WHERE name = 'id'")) + .isEqualTo("value1"); + assertThat( + getSingleResultFromNativeQuery( + "SELECT map -> 'key2' FROM \"TestEntity\" WHERE name = 'id'")) + .isEqualTo("value2"); + + executeNativeQuery("UPDATE \"TestEntity\" SET map = 'key3=>value3' WHERE name = 'id'"); + + assertThat( + getSingleResultFromNativeQuery( + "SELECT map -> 'key3' FROM \"TestEntity\" WHERE name = 'id'")) + .isEqualTo("value3"); + + executeNativeQuery("DELETE FROM \"TestEntity\" WHERE name = 'id'"); + assertThrows( + NoResultException.class, + () -> + getSingleResultFromNativeQuery( + "SELECT map -> 'key3' FROM \"TestEntity\" WHERE name = 'id'")); + } + + private static Object getSingleResultFromNativeQuery(String sql) { + return jpaTm() + .transact(() -> jpaTm().getEntityManager().createNativeQuery(sql).getSingleResult()); + } + + private static Object executeNativeQuery(String sql) { + return jpaTm() + .transact(() -> jpaTm().getEntityManager().createNativeQuery(sql).executeUpdate()); + } + + @Entity(name = "TestEntity") // Override entity name to avoid the nested class reference. + private static class TestEntity extends ImmutableObject { + + @Id String name = "id"; + + @Type(type = "google.registry.persistence.MapUserType") + Map map; + + private TestEntity() {} + + private TestEntity(Map map) { + this.map = map; + } + } +} diff --git a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java index 00e39a77b..93a67fac3 100644 --- a/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java +++ b/core/src/test/java/google/registry/schema/integration/SqlIntegrationTestSuite.java @@ -16,6 +16,7 @@ package google.registry.schema.integration; import google.registry.model.registry.RegistryLockDaoTest; import google.registry.model.transaction.JpaTestRules.JpaIntegrationTestRule; +import google.registry.persistence.MapUserTypeTest; import google.registry.schema.cursor.CursorDaoTest; import google.registry.schema.tld.PremiumListDaoTest; import google.registry.schema.tld.PremiumListUtilsTest; @@ -45,6 +46,7 @@ import org.junit.runners.Suite.SuiteClasses; CreateReservedListCommandTest.class, CursorDaoTest.class, CreatePremiumListActionTest.class, + MapUserTypeTest.class, PremiumListDaoTest.class, PremiumListUtilsTest.class, RegistryLockDaoTest.class, diff --git a/db/src/main/resources/sql/flyway/V14__load_extension_for_hstore.sql b/db/src/main/resources/sql/flyway/V14__load_extension_for_hstore.sql new file mode 100644 index 000000000..93fd75968 --- /dev/null +++ b/db/src/main/resources/sql/flyway/V14__load_extension_for_hstore.sql @@ -0,0 +1,15 @@ +-- Copyright 2020 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. + +CREATE EXTENSION IF NOT EXISTS hstore WITH SCHEMA public; diff --git a/db/src/main/resources/sql/schema/nomulus.golden.sql b/db/src/main/resources/sql/schema/nomulus.golden.sql index 3cd12a54c..9f36d8c07 100644 --- a/db/src/main/resources/sql/schema/nomulus.golden.sql +++ b/db/src/main/resources/sql/schema/nomulus.golden.sql @@ -16,6 +16,20 @@ SET xmloption = content; SET client_min_messages = warning; SET row_security = off; +-- +-- Name: hstore; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS hstore WITH SCHEMA public; + + +-- +-- Name: EXTENSION hstore; Type: COMMENT; Schema: -; Owner: - +-- + +COMMENT ON EXTENSION hstore IS 'data type for storing sets of (key, value) pairs'; + + SET default_tablespace = ''; SET default_with_oids = false;