diff --git a/core/src/main/java/google/registry/persistence/GenericCollectionUserType.java b/core/src/main/java/google/registry/persistence/GenericCollectionUserType.java new file mode 100644 index 000000000..8ced7615a --- /dev/null +++ b/core/src/main/java/google/registry/persistence/GenericCollectionUserType.java @@ -0,0 +1,127 @@ +// 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.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 { + + abstract T getNewCollection(); + + abstract ArrayColumnType getColumnType(); + + enum ArrayColumnType { + STRING(Types.ARRAY, "text"); + + final int typeCode; + final String typeName; + + ArrayColumnType(int typeCode, String typeName) { + this.typeCode = typeCode; + this.typeName = typeName; + } + + int getTypeCode() { + return typeCode; + } + + String getTypeName() { + return typeName; + } + + String getTypeDdlName() { + return typeName + "[]"; + } + } + + @Override + public int[] sqlTypes() { + 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) + throws HibernateException, SQLException { + if (rs.getArray(names[0]) != null) { + T result = getNewCollection(); + for (Object element : (Object[]) rs.getArray(names[0]).getArray()) { + result.add(element); + } + return result; + } + return null; + } + + @Override + public void nullSafeSet( + PreparedStatement st, Object value, int index, SharedSessionContractImplementor session) + throws HibernateException, SQLException { + if (value == null) { + st.setArray(index, null); + return; + } + T list = (T) value; + 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/NomulusPostgreSQLDialect.java b/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java index 50ecb273c..3766363a5 100644 --- a/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java +++ b/core/src/main/java/google/registry/persistence/NomulusPostgreSQLDialect.java @@ -13,6 +13,7 @@ // limitations under the License. package google.registry.persistence; +import google.registry.persistence.GenericCollectionUserType.ArrayColumnType; import java.sql.Types; import org.hibernate.dialect.PostgreSQL95Dialect; @@ -23,5 +24,8 @@ public class NomulusPostgreSQLDialect extends PostgreSQL95Dialect { registerColumnType(Types.VARCHAR, "text"); registerColumnType(Types.TIMESTAMP_WITH_TIMEZONE, "timestamptz"); registerColumnType(Types.TIMESTAMP, "timestamptz"); + for (ArrayColumnType arrayType : ArrayColumnType.values()) { + registerColumnType(arrayType.getTypeCode(), arrayType.getTypeDdlName()); + } } } diff --git a/core/src/main/java/google/registry/persistence/StringListUserType.java b/core/src/main/java/google/registry/persistence/StringListUserType.java new file mode 100644 index 000000000..08af4e32a --- /dev/null +++ b/core/src/main/java/google/registry/persistence/StringListUserType.java @@ -0,0 +1,37 @@ +// 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 com.google.common.collect.Lists; +import java.util.List; + +/** Abstract Hibernate user type for storing/retrieving {@link List}. */ +public class StringListUserType extends GenericCollectionUserType> { + + @Override + List getNewCollection() { + return Lists.newArrayList(); + } + + @Override + ArrayColumnType getColumnType() { + return ArrayColumnType.STRING; + } + + @Override + public Class returnedClass() { + return List.class; + } +} diff --git a/core/src/main/java/google/registry/persistence/StringSetUserType.java b/core/src/main/java/google/registry/persistence/StringSetUserType.java new file mode 100644 index 000000000..5df1d37e8 --- /dev/null +++ b/core/src/main/java/google/registry/persistence/StringSetUserType.java @@ -0,0 +1,37 @@ +// 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 com.google.common.collect.Sets; +import java.util.Set; + +/** Abstract Hibernate user type for storing/retrieving {@link Set}. */ +public class StringSetUserType extends GenericCollectionUserType> { + + @Override + Set getNewCollection() { + return Sets.newHashSet(); + } + + @Override + ArrayColumnType getColumnType() { + return ArrayColumnType.STRING; + } + + @Override + public Class returnedClass() { + return Set.class; + } +} diff --git a/core/src/test/java/google/registry/persistence/StringListUserTypeTest.java b/core/src/test/java/google/registry/persistence/StringListUserTypeTest.java new file mode 100644 index 000000000..bc0d41bda --- /dev/null +++ b/core/src/test/java/google/registry/persistence/StringListUserTypeTest.java @@ -0,0 +1,135 @@ +// 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 google.registry.testing.JUnitBackports.assertThrows; + +import com.google.common.collect.ImmutableList; +import google.registry.model.ImmutableObject; +import google.registry.model.transaction.JpaTestRules; +import google.registry.model.transaction.JpaTestRules.JpaUnitTestRule; +import java.util.List; +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 StringListUserType}. */ +@RunWith(JUnit4.class) +public class StringListUserTypeTest { + @Rule + public final JpaUnitTestRule jpaRule = + new JpaTestRules.Builder().withEntityClass(TestEntity.class).buildUnitTestRule(); + + @Test + public void roundTripConversion_returnsSameStringList() { + List tlds = ImmutableList.of("app", "dev", "how"); + TestEntity testEntity = new TestEntity(tlds); + jpaTm().transact(() -> jpaTm().getEntityManager().persist(testEntity)); + TestEntity persisted = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + assertThat(persisted.tlds).containsExactly("app", "dev", "how"); + } + + @Test + public void testMerge_succeeds() { + List tlds = ImmutableList.of("app", "dev", "how"); + TestEntity testEntity = new TestEntity(tlds); + jpaTm().transact(() -> jpaTm().getEntityManager().persist(testEntity)); + TestEntity persisted = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + persisted.tlds = ImmutableList.of("com", "gov"); + jpaTm().transact(() -> jpaTm().getEntityManager().merge(persisted)); + TestEntity updated = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + assertThat(updated.tlds).containsExactly("com", "gov"); + } + + @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.tlds).isNull(); + } + + @Test + public void testEmptyCollection_writesAndReadsEmptyCollectionSuccessfully() { + TestEntity testEntity = new TestEntity(ImmutableList.of()); + jpaTm().transact(() -> jpaTm().getEntityManager().persist(testEntity)); + TestEntity persisted = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + assertThat(persisted.tlds).isEmpty(); + } + + @Test + public void testNativeQuery_succeeds() throws Exception { + executeNativeQuery("INSERT INTO \"TestEntity\" (name, tlds) VALUES ('id', '{app, dev}')"); + + assertThat( + getSingleResultFromNativeQuery("SELECT tlds[1] FROM \"TestEntity\" WHERE name = 'id'")) + .isEqualTo("app"); + assertThat( + getSingleResultFromNativeQuery("SELECT tlds[2] FROM \"TestEntity\" WHERE name = 'id'")) + .isEqualTo("dev"); + + executeNativeQuery("UPDATE \"TestEntity\" SET tlds = '{com, gov}' WHERE name = 'id'"); + + assertThat( + getSingleResultFromNativeQuery("SELECT tlds[1] FROM \"TestEntity\" WHERE name = 'id'")) + .isEqualTo("com"); + assertThat( + getSingleResultFromNativeQuery("SELECT tlds[2] FROM \"TestEntity\" WHERE name = 'id'")) + .isEqualTo("gov"); + + executeNativeQuery("DELETE FROM \"TestEntity\" WHERE name = 'id'"); + assertThrows( + NoResultException.class, + () -> + getSingleResultFromNativeQuery("SELECT tlds[1] 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.StringListUserType") + List tlds; + + private TestEntity() {} + + private TestEntity(List tlds) { + this.tlds = tlds; + } + } +} diff --git a/core/src/test/java/google/registry/persistence/StringSetUserTypeTest.java b/core/src/test/java/google/registry/persistence/StringSetUserTypeTest.java new file mode 100644 index 000000000..0900075ef --- /dev/null +++ b/core/src/test/java/google/registry/persistence/StringSetUserTypeTest.java @@ -0,0 +1,82 @@ +// 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 com.google.common.collect.ImmutableSet; +import google.registry.model.ImmutableObject; +import google.registry.model.transaction.JpaTestRules; +import google.registry.model.transaction.JpaTestRules.JpaUnitTestRule; +import java.util.Set; +import javax.persistence.Entity; +import javax.persistence.Id; +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 StringSetUserType}. */ +@RunWith(JUnit4.class) +public class StringSetUserTypeTest { + @Rule + public final JpaUnitTestRule jpaRule = + new JpaTestRules.Builder().withEntityClass(TestEntity.class).buildUnitTestRule(); + + @Test + public void roundTripConversion_returnsSameStringList() { + Set tlds = ImmutableSet.of("app", "dev", "how"); + TestEntity testEntity = new TestEntity(tlds); + jpaTm().transact(() -> jpaTm().getEntityManager().persist(testEntity)); + TestEntity persisted = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + assertThat(persisted.tlds).containsExactly("app", "dev", "how"); + } + + @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.tlds).isNull(); + } + + @Test + public void testEmptyCollection_writesAndReadsEmptyCollectionSuccessfully() { + TestEntity testEntity = new TestEntity(ImmutableSet.of()); + jpaTm().transact(() -> jpaTm().getEntityManager().persist(testEntity)); + TestEntity persisted = + jpaTm().transact(() -> jpaTm().getEntityManager().find(TestEntity.class, "id")); + assertThat(persisted.tlds).isEmpty(); + } + + @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.StringSetUserType") + Set tlds; + + private TestEntity() {} + + private TestEntity(Set tlds) { + this.tlds = tlds; + } + } +}