From 05363b8ef789b5a433130f2c5d9b9e1f5bb5b106 Mon Sep 17 00:00:00 2001 From: Peter Moser Date: Thu, 26 Sep 2024 15:23:27 +0200 Subject: [PATCH 1/8] add query transformer --- .../transformer/QueryTransformer.java | 155 +++++++++++ .../TransformerRuntimeException.java | 22 ++ .../transformer/TupleTransformer.java | 188 +++++++++++++ .../transformer/QueryTransformerTest.java | 247 ++++++++++++++++++ .../transformer/TupleTransformerTest.java | 188 +++++++++++++ .../impl/jpa/QueryTransformerTestModel.java | 51 ++++ .../QueryTransformerTestModelRepository.java | 6 + ...create-query-transformer-testing-table.yml | 21 ++ src/test/resources/db/changelog/master.yml | 3 + 9 files changed, 881 insertions(+) create mode 100644 src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java create mode 100644 src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TransformerRuntimeException.java create mode 100644 src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java create mode 100644 src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModelRepository.java create mode 100644 src/test/resources/db/changelog/2024-09-26-create-query-transformer-testing-table.yml diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java new file mode 100644 index 0000000..92a624b --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java @@ -0,0 +1,155 @@ +package it.aboutbits.springboot.toolbox.persistence.transformer; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityNotFoundException; +import jakarta.persistence.Query; +import jakarta.persistence.TypedQuery; +import org.hibernate.query.NativeQuery; +import org.hibernate.transform.ResultTransformer; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.Optional; + +@SuppressWarnings({"rawtypes"}) +public final class QueryTransformer { + + private final EntityManager entityManager; + private final TupleTransformer tupleTransformer; + private org.hibernate.query.Query unwrappedQuery; + private boolean isNative = false; + + private QueryTransformer(final EntityManager entityManager, final Class outputClass) { + this.entityManager = entityManager; + this.tupleTransformer = new TupleTransformer<>(outputClass); + } + + public static QueryTransformer of(final EntityManager entityManager, final Class outputClass) { + return new QueryTransformer<>(entityManager, outputClass); + } + + public QueryTransformer withQuery(final Query query) { + if (query instanceof NativeQuery) { + this.isNative = true; + } + this.unwrappedQuery = query.unwrap(org.hibernate.query.Query.class); + return this; + } + + public Page asPage(Pageable pageable) { + return asPage(pageable.getPageNumber(), pageable.getPageSize()); + } + + public Page asPage(final int pageNumber, final int pageSize) { + return isNative ? asPageNativeQuery(pageNumber, pageSize) : asPageQuery(pageNumber, pageSize); + } + + public List asList() { + return asList(null, null); + } + + public Optional asSingleResult() { + var result = asList(); + if (result.isEmpty()) { + return Optional.empty(); + } + if (result.size() > 1) { + throw new IllegalStateException("Single result query returned multiple results!"); + } + return Optional.of(result.get(0)); + } + + public T asSingleResultOrFail() { + return asSingleResult() + .orElseThrow(EntityNotFoundException::new); + } + + @SuppressWarnings({"deprecation", "unchecked"}) + private List asList(final Integer pageNumber, final Integer pageSize) { + unwrappedQuery.setResultTransformer( + (ResultTransformer) (objects, aliases) -> tupleTransformer.transform(objects) + ); + + if (pageSize != null && pageNumber != null) { + unwrappedQuery + .setMaxResults(pageSize) + .setFirstResult(pageSize * pageNumber); + } + + return unwrappedQuery.getResultList(); + } + + private Page asPageQuery(final int pageNumber, final int pageSize) { + var selectPattern = "(?i)select.*?[ \\t]*from "; + var queryString = unwrappedQuery.getQueryString().trim().replaceAll("\\R", " "); + var countQueryString = queryString.replaceFirst(selectPattern, "select count(*) from "); + countQueryString = countQueryString.replaceAll("(?i)\\s+order\\s+by\\s+.*$", ""); + + if (queryString.toLowerCase().contains("select distinct")) { + throw new IllegalStateException("Pagination is not possible, if SELECT DISTINCT is present"); + } + + if (countQueryString.equals(queryString)) { + throw new IllegalStateException("Unable to find SELECT ... FROM in query string!"); + } + + var parameters = unwrappedQuery.getParameters(); + var countQuery = entityManager.createQuery(countQueryString, Long.class); + for (var parameter : parameters) { + var value = unwrappedQuery.getParameterValue(parameter.getName()); + countQuery.setParameter(parameter.getName(), value); + } + + var count = getCount(countQuery, queryString); + + var content = asList(pageNumber, pageSize); + + return new PageImpl<>(content, Pageable.ofSize(pageSize).withPage(pageNumber), count); + } + + private Page asPageNativeQuery(final int pageNumber, final int pageSize) { + var queryString = unwrappedQuery.getQueryString().trim().replaceAll("\\R", " "); + var countQueryString = "select count(*) from (" + queryString + ") as count"; + var parameters = unwrappedQuery.getParameters(); + var countQuery = isNative ? entityManager.createNativeQuery(countQueryString, Long.class) : entityManager.createQuery(countQueryString, Long.class); + for (var parameter : parameters) { + var value = unwrappedQuery.getParameterValue(parameter.getPosition()); + countQuery.setParameter(parameter.getPosition(), value); + } + + var count = getCount(countQuery); + var content = asList(pageNumber, pageSize); + return new PageImpl<>(content, Pageable.ofSize(pageSize).withPage(pageNumber), count); + } + + /** + * A "group by" clause generates a count for each group, counting the members of that group. + * So, if we find a "group by" inside the query string we just count the groups and do not sum the count within + * them. + */ + private static long getCount(final TypedQuery countQuery, final String queryString) { + var countQueryResults = countQuery.getResultList(); + if (countQueryResults == null || countQueryResults.isEmpty()) { + return 0L; + } + + // Grouping query: count the groups and do not sum the count within them + if (queryString.toLowerCase().contains("group by")) { + return countQueryResults.size(); + } + + // Non-grouping query: return the first element, which is the result of count(*) + return countQueryResults.get(0); + } + + private static long getCount(final Query countQuery) { + var countQueryResults = countQuery.getResultList(); + if (countQueryResults == null || countQueryResults.isEmpty()) { + return 0L; + } + // Non-grouping query: return the first element, which is the result of count(*) + return (long) countQueryResults.get(0); + } +} diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TransformerRuntimeException.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TransformerRuntimeException.java new file mode 100644 index 0000000..316072f --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TransformerRuntimeException.java @@ -0,0 +1,22 @@ +package it.aboutbits.springboot.toolbox.persistence.transformer; + +public class TransformerRuntimeException extends RuntimeException { + public TransformerRuntimeException() { + } + + public TransformerRuntimeException(final String message) { + super(message); + } + + public TransformerRuntimeException(final String message, final Throwable cause) { + super(message, cause); + } + + public TransformerRuntimeException(final Throwable cause) { + super(cause); + } + + public TransformerRuntimeException(final String message, final Throwable cause, final boolean enableSuppression, final boolean writableStackTrace) { + super(message, cause, enableSuppression, writableStackTrace); + } +} diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java new file mode 100644 index 0000000..66bdf85 --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java @@ -0,0 +1,188 @@ +package it.aboutbits.springboot.toolbox.persistence.transformer; + +import it.aboutbits.springboot.toolbox.type.CustomType; +import it.aboutbits.springboot.toolbox.type.ScaledBigDecimal; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.Arrays; + +@SuppressWarnings("rawtypes") +public class TupleTransformer { + + private final Class outputClass; + private final Constructor outputClassConstructor; + private final Class[] outputClassFieldClasses; + + public TupleTransformer(final Class outputClass) { + this.outputClass = outputClass; + + // Find all fields and their types inside the result class + // Ignore constants, because they will not be used as constructor parameters + outputClassFieldClasses = Arrays + .stream(outputClass.getDeclaredFields()) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .map(Field::getType) + .toArray(Class[]::new); + + // Find the all-args-constructor inside the result class + try { + outputClassConstructor = outputClass.getDeclaredConstructor(outputClassFieldClasses); + outputClassConstructor.setAccessible(true); + } catch (NoSuchMethodException exception) { + throw new TransformerRuntimeException( + String.format( + "Query transformation: Could not find a valid constructor in target class %s", + outputClass.getName() + ), + exception + ); + } + } + + @SuppressWarnings("unchecked") + public T transform(final Object[] objects) { + try { + + // If we have a single entry in the result, and that entry matches the desired result class + // we can just give it back, no casting, nor type-checking needed. We can just unbox it and + // give it back as-is! + // Example: "SELECT p FROM Person p" + if (objects.length == 1 && outputClass == objects[0].getClass()) { + return (T) objects[0]; + } + + if (objects.length != outputClassFieldClasses.length) { + throw new TransformerRuntimeException( + String.format( + "Invalid query transforming: object count does not match target class field count for %s", + outputClass.getName() + ) + ); + } + + // Unboxing not possible, we have a complex combined result, check single record entries for type-safety! + for (var i = 0; i < objects.length; i++) { + + // Everything ok, null matches every object and equal classes do not need casting! + // Unboxing of primitives is automatic when we call the constructor of the target result class. + if (objects[i] == null || outputClassFieldClasses[i].isPrimitive() || objects[i].getClass() == outputClassFieldClasses[i]) { + continue; + } + + // Check if the two classes are either the same, or if it is a superclass or superinterface of it... + // For example, casting an ArrayList to List can be done directly + if (outputClassFieldClasses[i].isAssignableFrom(objects[i].getClass())) { + objects[i] = outputClassFieldClasses[i].cast(objects[i]); + continue; + } + + // Converter: STRING to ENUM + // A string from the DB, that does not match a corresponding field inside the result class + // should probably be an enum value, which implements the "valueOf" interface. + if (objects[i] instanceof String && outputClassFieldClasses[i].isEnum()) { + objects[i] = outputClassFieldClasses[i].getMethod("valueOf", String.class).invoke( + null, + objects[i].toString() + ); + continue; + } + + // Converter: to WrappedValue + if (CustomType.class.isAssignableFrom(outputClassFieldClasses[i])) { + objects[i] = toWrappedValue(objects[i], outputClassFieldClasses[i]); + continue; + } + + // Converter: Instant to OffsetDateTime + if (objects[i] instanceof Instant && outputClassFieldClasses[i].isAssignableFrom(OffsetDateTime.class)) { + objects[i] = OffsetDateTime.ofInstant( + (Instant) objects[i], + ZoneId.systemDefault() + ); + continue; + } + + // Non-matching classes in fields. No converter found... + throw new UnsupportedOperationException( + String.format( + "Query transformation: Type mismatch without converter. Cannot cast from %s to %s.", + objects[i].getClass().getName(), + outputClassFieldClasses[i].getName() + ) + ); + } + + return outputClassConstructor.newInstance(objects); + + } catch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException + | UnsupportedOperationException exception) { + throw new TransformerRuntimeException( + String.format( + "Query transformation: Given database record cannot be converted into target class %s", + outputClass.getName() + ), + exception + ); + } + } + + @SuppressWarnings("unchecked") + private > X toWrappedValue( + Object actualValue, + Class targetType + ) throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { + // types with a preferred constructor + if (targetType.isAssignableFrom(ScaledBigDecimal.class)) { + var constructor = targetType.getDeclaredConstructor(Double.class); + var value = (Double) actualValue; + return constructor.newInstance(value); + } + + // types using first suitable constructor + Constructor[] constructors = targetType.getDeclaredConstructors(); + + for (Constructor constructor : constructors) { + Class[] parameterTypes = constructor.getParameterTypes(); + if (parameterTypes.length == 1) { + if (Number.class.isAssignableFrom(parameterTypes[0])) { + if (Long.class.equals(parameterTypes[0])) { + var val = (Long) actualValue; + return (X) constructor.newInstance(val); + } else if (Integer.class.equals(parameterTypes[0])) { + var val = (Integer) actualValue; + return (X) constructor.newInstance(val); + } else if (Double.class.equals(parameterTypes[0])) { + var val = (Double) actualValue; + return (X) constructor.newInstance(val); + } else if (Float.class.equals(parameterTypes[0])) { + var val = (Float) actualValue; + return (X) constructor.newInstance(val); + } else if (BigInteger.class.equals(parameterTypes[0])) { + var val = BigInteger.valueOf((Long) actualValue); + return (X) constructor.newInstance(val); + } else if (BigDecimal.class.equals(parameterTypes[0])) { + var val = BigDecimal.valueOf((Double) actualValue); + return (X) constructor.newInstance(val); + } else { + throw new IllegalArgumentException("Unsupported number type"); + } + } else if (String.class.equals(parameterTypes[0])) { + var val = (String) actualValue; + return (X) constructor.newInstance(val); + } else if (Boolean.class.equals(parameterTypes[0])) { + var val = (Boolean) actualValue; + return (X) constructor.newInstance(val); + } // Add more types as needed + } + } + throw new IllegalArgumentException(targetType.getSimpleName() + " does not have a suitable single-value constructor"); + } +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java new file mode 100644 index 0000000..4ee5ed5 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java @@ -0,0 +1,247 @@ +package it.aboutbits.springboot.toolbox.persistence.transformer; + +import it.aboutbits.springboot.toolbox.persistence.transformer.impl.jpa.QueryTransformerTestModel; +import it.aboutbits.springboot.toolbox.persistence.transformer.impl.jpa.QueryTransformerTestModelRepository; +import it.aboutbits.springboot.toolbox.support.ApplicationTest; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@ApplicationTest +public class QueryTransformerTest { + + @Autowired + EntityManager entityManager; + + @Autowired + QueryTransformerTestModelRepository repository; + + @Test + void givenQueryWithNoResults_asSingleResult_shouldPass() { + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, UserContainer.class) + .withQuery(query) + .asSingleResult(); + + assertThat(result).isNotPresent(); + } + + @Test + void givenQueryWithOneResult_asSingleResult_shouldPass() { + createTestModel("abc"); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, UserContainer.class) + .withQuery(query) + .asSingleResult(); + + assertThat(result).isPresent(); + assertThat(result.get().someText).isEqualTo("xxx"); + assertThat(result.get().testModel.getName()).isEqualTo("abc"); + } + + @Test + void givenQueryWithOneResult_asSingleResultOrFail_shouldPass() { + createTestModel("abc"); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, UserContainer.class) + .withQuery(query) + .asSingleResultOrFail(); + + assertThat(result.someText).isEqualTo("xxx"); + } + + @Test + void givenQueryWithOneResult_asSingleResultOrFail_shouldFail() { + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + assertThrows(EntityNotFoundException.class, () -> QueryTransformer + .of(entityManager, UserContainer.class) + .withQuery(query) + .asSingleResultOrFail()); + } + + @Test + void givenQueryWithMultipleResults_asSingleResult_shouldFail() { + createTestModel("A"); + createTestModel("B"); + createTestModel("C"); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + assertThrows( + IllegalStateException.class, + () -> QueryTransformer + .of(entityManager, UserContainer.class) + .withQuery(query) + .asSingleResult() + ); + } + + @Test + void givenQuery_singleUnboxedEntityResult_asList_shouldPass() { + var user = createTestModel("abc"); + + var query = entityManager.createQuery("select q from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, QueryTransformerTestModel.class) + .withQuery(query) + .asList(); + + assertThat(result.getFirst().getId()).isEqualTo(user.getId()); + assertThat(result.getFirst().getName()).isEqualTo(user.getName()); + } + + + @Test + void givenQuery_asList_shouldPass() { + var user = createTestModel("abc"); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, UserContainer.class) + .withQuery(query) + .asList(); + + assertThat(result.getFirst().testModel().getId()).isEqualTo(user.getId()); + assertThat(result.getFirst().testModel().getName()).isEqualTo(user.getName()); + assertThat(result.getFirst().someText).isEqualTo("xxx"); + } + + + @Test + void givenQuery_asPage_shouldPass() { + var user = createTestModel("abc"); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, UserContainer.class) + .withQuery(query) + .asPage(0, 2); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getNumber()).isEqualTo(0); + assertThat(result.getSize()).isEqualTo(2); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().testModel().getId()).isEqualTo(user.getId()); + assertThat(result.getContent().getFirst().testModel().getName()).isEqualTo(user.getName()); + assertThat(result.getContent().getFirst().someText()).isEqualTo("xxx"); + } + + @Test + void givenQueryWithNoResults_asPage_shouldPass() { + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, UserContainer.class) + .withQuery(query) + .asPage(0, 2); + + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getContent()).hasSize(0); + } + + @Test + void givenQueryWithNoResults2_asPage_shouldPass() { + var query = entityManager.createQuery("select q from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, UserContainer.class) + .withQuery(query) + .asPage(0, 2); + + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getContent()).hasSize(0); + } + + @Test + void givenQueryWithGroupBy_asPage_shouldPass() { + createTestModel("A"); + createTestModel("B"); + createTestModel("A"); + createTestModel("C"); + + var query = entityManager.createQuery( + "select count(q.email), q.name from QueryTransformerTestModel q group by q.name order by q.name desc" + ); + + var resultPage0 = QueryTransformer + .of(entityManager, UserCount.class) + .withQuery(query) + .asPage(0, 2); + + // We have 3 groups: A, B, and C + assertThat(resultPage0.getTotalElements()).isEqualTo(3); + assertThat(resultPage0.getTotalPages()).isEqualTo(2); + assertThat(resultPage0.getContent()).hasSize(2); + + var sameQuery = entityManager.createQuery( + "select count(q.email), q.name from QueryTransformerTestModel q group by q.name order by q.name desc" + ); + + var resultPage1 = QueryTransformer + .of(entityManager, UserCount.class) + .withQuery(sameQuery) + .asPage(1, 2); + + assertThat(resultPage1.getTotalElements()).isEqualTo(3); + assertThat(resultPage1.getTotalPages()).isEqualTo(2); + assertThat(resultPage1.getContent()).hasSize(1); + } + + + @Test + void givenQuery_asList_wrongTargetClass_shouldFail() { + createTestModel("abc"); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + assertThrows( + TransformerRuntimeException.class, + () -> QueryTransformer + .of(entityManager, WrongContainer.class) + .withQuery(query) + .asList() + ); + } + + protected record UserCount( + long count, + String familyName + ) { + } + + protected record UserContainer( + QueryTransformerTestModel testModel, + String someText + ) { + } + + + protected record WrongContainer( + Long someNumber, + String someText + ) { + } + + private QueryTransformerTestModel createTestModel(String name) { + var item = new QueryTransformerTestModel(); + item.setName(name); + item.setEmail(null); + return repository.save(item); + } +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java new file mode 100644 index 0000000..45c9f85 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java @@ -0,0 +1,188 @@ +package it.aboutbits.springboot.toolbox.persistence.transformer; + +import lombok.Data; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TupleTransformerTest { + + @Test + void createObjectWithPrimitiveFields_givenPrimitives_shouldPass() { + // given + var tupleTransformer = new TupleTransformer<>(DataClassWithPrimitives.class); + + // when + var result = tupleTransformer.transform(new Object[] {7L, 12, true}); + + // then + assertThat(result.longField).isEqualTo(7L); + assertThat(result.intField).isEqualTo(12); + assertThat(result.booleanField).isTrue(); + } + + @Test + void createObjectWithPrimitiveFields_givenBoxedObjects_shouldPass() { + // given + var tupleTransformer = new TupleTransformer<>(DataClassWithPrimitives.class); + var l = Long.valueOf(7); + var i = Integer.valueOf(12); + var b = Boolean.valueOf(true); + var objectsUnderTest = new Object[] {l, i, b}; + + // when + var result = tupleTransformer.transform(objectsUnderTest); + + // then + assertThat(result.longField).isEqualTo(7L); + assertThat(result.intField).isEqualTo(12); + assertThat(result.booleanField).isTrue(); + } + + + @Test + void createRecord_givenMixedObjects_shouldPass() { + // given + var tupleTransformer = new TupleTransformer<>(DataRecord.class); + var l = Long.valueOf(7); + var b = Boolean.valueOf(true); + var objectsUnderTest = new Object[] {l, b, "String123", false, SomeEnum.ENUM_1}; + + // when + var result = tupleTransformer.transform(objectsUnderTest); + + // then + assertThat(result.longField).isEqualTo(7L); + assertThat(result.booleanBoxed).isTrue(); + assertThat(result.string).isEqualTo("String123"); + assertThat(result.booleanPrimitive).isFalse(); + assertThat(result.anEnum).isEqualTo(SomeEnum.ENUM_1); + } + + + @Test + void createRecord_givenMixedObjects_enumValueAsString_shouldPass() { + // given + var tupleTransformer = new TupleTransformer<>(DataRecord.class); + var l = Long.valueOf(7); + var b = Boolean.valueOf(true); + var objectsUnderTest = new Object[] {l, b, "String123", false, "ENUM_1"}; + + // when + var result = tupleTransformer.transform(objectsUnderTest); + + // then + assertThat(result.longField).isEqualTo(7L); + assertThat(result.booleanBoxed).isTrue(); + assertThat(result.string).isEqualTo("String123"); + assertThat(result.booleanPrimitive).isFalse(); + assertThat(result.anEnum).isEqualTo(SomeEnum.ENUM_1); + } + + + @Test + void createRecordInsideAClass_givenMixedObjects_shouldPass() { + // given + var tupleTransformer = new TupleTransformer<>(DataRecordParent.class); + var rec = new DataRecord(7L, true, "String123", false, SomeEnum.ENUM_1); + var objectsUnderTest = new Object[] {rec, 33}; + + // when + var result = tupleTransformer.transform(objectsUnderTest); + + // then + assertThat(result.dataRecord.longField).isEqualTo(7L); + assertThat(result.dataRecord.booleanBoxed).isTrue(); + assertThat(result.dataRecord.string).isEqualTo("String123"); + assertThat(result.dataRecord.booleanPrimitive).isFalse(); + assertThat(result.dataRecord.anEnum).isEqualTo(SomeEnum.ENUM_1); + assertThat(result.someOtherField).isEqualTo(33); + } + + + @Test + void createRecord_givenMixedObjects_someNullValues_shouldPass() { + // given + var tupleTransformer = new TupleTransformer<>(DataRecord.class); + var objectsUnderTest = new Object[] {null, null, "String123", false, null}; + + // when + var result = tupleTransformer.transform(objectsUnderTest); + + // then + assertThat(result.longField).isNull(); + assertThat(result.booleanBoxed).isNull(); + assertThat(result.string).isEqualTo("String123"); + assertThat(result.booleanPrimitive).isFalse(); + assertThat(result.anEnum).isNull(); + } + + + @Test + void createRecordInsideAClass_givenMixedObjectsAsList_shouldPass() { + // given + var tupleTransformer = new TupleTransformer<>(DataRecordParentWithList.class); + var rec1 = new DataRecord(7L, true, "String111", false, SomeEnum.ENUM_1); + var rec2 = new DataRecord(0L, null, "String222", false, SomeEnum.ENUM_2); + var list = new ArrayList<>(); + list.add(rec1); + list.add(rec2); + var objectsUnderTest = new Object[] {list, 33}; + + // when + var result = tupleTransformer.transform(objectsUnderTest); + + // then + var firstRecord = result.dataRecords.get(0); + assertThat(firstRecord.longField).isEqualTo(7L); + assertThat(firstRecord.booleanBoxed).isTrue(); + assertThat(firstRecord.string).isEqualTo("String111"); + assertThat(firstRecord.booleanPrimitive).isFalse(); + assertThat(firstRecord.anEnum).isEqualTo(SomeEnum.ENUM_1); + + var secondRecord = result.dataRecords.get(1); + assertThat(secondRecord.longField).isEqualTo(0L); + assertThat(secondRecord.booleanBoxed).isNull(); + assertThat(secondRecord.string).isEqualTo("String222"); + assertThat(secondRecord.booleanPrimitive).isFalse(); + assertThat(secondRecord.anEnum).isEqualTo(SomeEnum.ENUM_2); + + assertThat(result.someOtherField).isEqualTo(33); + } + + + @Data + private static final class DataClassWithPrimitives { + private final long longField; + private final int intField; + private final boolean booleanField; + } + + protected record DataRecord( + Long longField, + Boolean booleanBoxed, + String string, + boolean booleanPrimitive, + SomeEnum anEnum + ) { } + + + protected record DataRecordParent( + DataRecord dataRecord, + long someOtherField + ) { } + + + protected record DataRecordParentWithList( + List dataRecords, + long someOtherField + ) { } + + protected enum SomeEnum { + ENUM_1, + ENUM_2 + } +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java new file mode 100644 index 0000000..fadff06 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java @@ -0,0 +1,51 @@ +package it.aboutbits.springboot.toolbox.persistence.transformer.impl.jpa; + +import it.aboutbits.springboot.toolbox.autoconfiguration.persistence.AutoRegisteredJavaType; +import it.aboutbits.springboot.toolbox.persistence.javatype.base.WrappedLongJavaType; +import it.aboutbits.springboot.toolbox.type.identity.EntityId; +import it.aboutbits.springboot.toolbox.type.identity.Identified; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.JavaType; + +@Entity +@Getter +@Setter +@Builder +@Table(name = "query_transformer_test_model") +@NoArgsConstructor +@AllArgsConstructor +public class QueryTransformerTestModel implements Identified { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @JavaType(ID.JavaType.class) + private ID id; + + private String name; + + private String email; + + public record ID( + Long value + ) implements EntityId { + + @Override + public String toString() { + return String.valueOf(value()); + } + + public static class JavaType extends WrappedLongJavaType implements AutoRegisteredJavaType { + public JavaType() { + super(ID.class); + } + } + } +} diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModelRepository.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModelRepository.java new file mode 100644 index 0000000..cbeb0eb --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModelRepository.java @@ -0,0 +1,6 @@ +package it.aboutbits.springboot.toolbox.persistence.transformer.impl.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QueryTransformerTestModelRepository extends JpaRepository { +} diff --git a/src/test/resources/db/changelog/2024-09-26-create-query-transformer-testing-table.yml b/src/test/resources/db/changelog/2024-09-26-create-query-transformer-testing-table.yml new file mode 100644 index 0000000..f05612b --- /dev/null +++ b/src/test/resources/db/changelog/2024-09-26-create-query-transformer-testing-table.yml @@ -0,0 +1,21 @@ +databaseChangeLog: + - changeSet: + author: Andreas Hufler + id: 2024-09-26-create-query-transformer-testing-table + changes: + - createTable: + tableName: query_transformer_test_model + columns: + - column: + name: id + autoIncrement: true + type: bigserial + constraints: + nullable: false + primaryKey: true + - column: + name: name + type: text + - column: + name: email + type: text diff --git a/src/test/resources/db/changelog/master.yml b/src/test/resources/db/changelog/master.yml index ae4ea78..151d81c 100644 --- a/src/test/resources/db/changelog/master.yml +++ b/src/test/resources/db/changelog/master.yml @@ -5,3 +5,6 @@ databaseChangeLog: - include: file: 2024-09-06-create-wrapper-type-testing-table.yml relativeToChangelogFile: true + - include: + file: 2024-09-26-create-query-transformer-testing-table.yml + relativeToChangelogFile: true From 06d09c11ef5fa23ee92d2523844bdb9c518b1713 Mon Sep 17 00:00:00 2001 From: Peter Moser Date: Thu, 26 Sep 2024 15:35:56 +0200 Subject: [PATCH 2/8] refactor tests --- .../transformer/QueryTransformerTest.java | 366 +++++++++--------- .../impl/jpa/QueryTransformerTestModel.java | 6 + ...create-query-transformer-testing-table.yml | 5 +- 3 files changed, 200 insertions(+), 177 deletions(-) diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java index 4ee5ed5..4ee993b 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java @@ -5,6 +5,7 @@ import it.aboutbits.springboot.toolbox.support.ApplicationTest; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityNotFoundException; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -20,218 +21,231 @@ public class QueryTransformerTest { @Autowired QueryTransformerTestModelRepository repository; - @Test - void givenQueryWithNoResults_asSingleResult_shouldPass() { - var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - - var result = QueryTransformer - .of(entityManager, UserContainer.class) - .withQuery(query) - .asSingleResult(); - - assertThat(result).isNotPresent(); - } - - @Test - void givenQueryWithOneResult_asSingleResult_shouldPass() { - createTestModel("abc"); - - var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - - var result = QueryTransformer - .of(entityManager, UserContainer.class) - .withQuery(query) - .asSingleResult(); - - assertThat(result).isPresent(); - assertThat(result.get().someText).isEqualTo("xxx"); - assertThat(result.get().testModel.getName()).isEqualTo("abc"); + @Nested + class AsSingleResult { + + @Test + void givenQueryWithNoResults_shouldPass() { + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asSingleResult(); + + assertThat(result).isNotPresent(); + } + + @Test + void givenQueryWithOneResult_shouldPass() { + createTestModel("abc"); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asSingleResult(); + + assertThat(result).isPresent(); + assertThat(result.get().someText).isEqualTo("xxx"); + assertThat(result.get().testModel.getName()).isEqualTo("abc"); + } + + @Test + void givenQueryWithMultipleResults_shouldFail() { + createTestModel("A"); + createTestModel("B"); + createTestModel("C"); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + assertThrows( + IllegalStateException.class, + () -> QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asSingleResult() + ); + } } - @Test - void givenQueryWithOneResult_asSingleResultOrFail_shouldPass() { - createTestModel("abc"); + @Nested + class AsSingleResultOrFail { - var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - - var result = QueryTransformer - .of(entityManager, UserContainer.class) - .withQuery(query) - .asSingleResultOrFail(); - - assertThat(result.someText).isEqualTo("xxx"); - } - - @Test - void givenQueryWithOneResult_asSingleResultOrFail_shouldFail() { - var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - - assertThrows(EntityNotFoundException.class, () -> QueryTransformer - .of(entityManager, UserContainer.class) - .withQuery(query) - .asSingleResultOrFail()); - } + @Test + void givenQueryWithOneResult_shouldPass() { + createTestModel("abc"); - @Test - void givenQueryWithMultipleResults_asSingleResult_shouldFail() { - createTestModel("A"); - createTestModel("B"); - createTestModel("C"); - - var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - - assertThrows( - IllegalStateException.class, - () -> QueryTransformer - .of(entityManager, UserContainer.class) - .withQuery(query) - .asSingleResult() - ); - } + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - @Test - void givenQuery_singleUnboxedEntityResult_asList_shouldPass() { - var user = createTestModel("abc"); + var result = QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asSingleResultOrFail(); - var query = entityManager.createQuery("select q from QueryTransformerTestModel q"); + assertThat(result.someText).isEqualTo("xxx"); + } - var result = QueryTransformer - .of(entityManager, QueryTransformerTestModel.class) - .withQuery(query) - .asList(); + @Test + void givenQueryWithOneResult_shouldFail() { + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - assertThat(result.getFirst().getId()).isEqualTo(user.getId()); - assertThat(result.getFirst().getName()).isEqualTo(user.getName()); + assertThrows(EntityNotFoundException.class, () -> QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asSingleResultOrFail()); + } } + @Nested + class AsList { - @Test - void givenQuery_asList_shouldPass() { - var user = createTestModel("abc"); - - var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + @Test + void givenQuery_singleUnboxedEntityResult_shouldPass() { + var testModel = createTestModel("abc"); - var result = QueryTransformer - .of(entityManager, UserContainer.class) - .withQuery(query) - .asList(); + var query = entityManager.createQuery("select q from QueryTransformerTestModel q"); - assertThat(result.getFirst().testModel().getId()).isEqualTo(user.getId()); - assertThat(result.getFirst().testModel().getName()).isEqualTo(user.getName()); - assertThat(result.getFirst().someText).isEqualTo("xxx"); - } + var result = QueryTransformer + .of(entityManager, QueryTransformerTestModel.class) + .withQuery(query) + .asList(); + assertThat(result.getFirst().getId()).isEqualTo(testModel.getId()); + assertThat(result.getFirst().getName()).isEqualTo(testModel.getName()); + } - @Test - void givenQuery_asPage_shouldPass() { - var user = createTestModel("abc"); - var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + @Test + void givenQuery_shouldPass() { + var testModel = createTestModel("abc"); - var result = QueryTransformer - .of(entityManager, UserContainer.class) - .withQuery(query) - .asPage(0, 2); + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - assertThat(result.getTotalElements()).isEqualTo(1); - assertThat(result.getNumber()).isEqualTo(0); - assertThat(result.getSize()).isEqualTo(2); - assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().getFirst().testModel().getId()).isEqualTo(user.getId()); - assertThat(result.getContent().getFirst().testModel().getName()).isEqualTo(user.getName()); - assertThat(result.getContent().getFirst().someText()).isEqualTo("xxx"); - } + var result = QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asList(); - @Test - void givenQueryWithNoResults_asPage_shouldPass() { - var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - - var result = QueryTransformer - .of(entityManager, UserContainer.class) - .withQuery(query) - .asPage(0, 2); - - assertThat(result.getTotalElements()).isEqualTo(0); - assertThat(result.getContent()).hasSize(0); - } + assertThat(result.getFirst().testModel().getId()).isEqualTo(testModel.getId()); + assertThat(result.getFirst().testModel().getName()).isEqualTo(testModel.getName()); + assertThat(result.getFirst().someText).isEqualTo("xxx"); + } - @Test - void givenQueryWithNoResults2_asPage_shouldPass() { - var query = entityManager.createQuery("select q from QueryTransformerTestModel q"); + @Test + void givenQuery_wrongTargetClass_shouldFail() { + createTestModel("abc"); - var result = QueryTransformer - .of(entityManager, UserContainer.class) - .withQuery(query) - .asPage(0, 2); + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - assertThat(result.getTotalElements()).isEqualTo(0); - assertThat(result.getContent()).hasSize(0); + assertThrows( + TransformerRuntimeException.class, + () -> QueryTransformer + .of(entityManager, WrongContainer.class) + .withQuery(query) + .asList() + ); + } } - @Test - void givenQueryWithGroupBy_asPage_shouldPass() { - createTestModel("A"); - createTestModel("B"); - createTestModel("A"); - createTestModel("C"); - - var query = entityManager.createQuery( - "select count(q.email), q.name from QueryTransformerTestModel q group by q.name order by q.name desc" - ); - - var resultPage0 = QueryTransformer - .of(entityManager, UserCount.class) - .withQuery(query) - .asPage(0, 2); - - // We have 3 groups: A, B, and C - assertThat(resultPage0.getTotalElements()).isEqualTo(3); - assertThat(resultPage0.getTotalPages()).isEqualTo(2); - assertThat(resultPage0.getContent()).hasSize(2); - - var sameQuery = entityManager.createQuery( - "select count(q.email), q.name from QueryTransformerTestModel q group by q.name order by q.name desc" - ); - - var resultPage1 = QueryTransformer - .of(entityManager, UserCount.class) - .withQuery(sameQuery) - .asPage(1, 2); - - assertThat(resultPage1.getTotalElements()).isEqualTo(3); - assertThat(resultPage1.getTotalPages()).isEqualTo(2); - assertThat(resultPage1.getContent()).hasSize(1); + @Nested + class AsPage { + + @Test + void givenQuery_shouldPass() { + var testModel = createTestModel("abc"); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asPage(0, 2); + + assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getNumber()).isEqualTo(0); + assertThat(result.getSize()).isEqualTo(2); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().testModel().getId()).isEqualTo(testModel.getId()); + assertThat(result.getContent().getFirst().testModel().getName()).isEqualTo(testModel.getName()); + assertThat(result.getContent().getFirst().someText()).isEqualTo("xxx"); + } + + @Test + void givenQueryWithNoResults_shouldPass() { + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asPage(0, 2); + + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getContent()).hasSize(0); + } + + @Test + void givenQueryWithNoResults2_shouldPass() { + var query = entityManager.createQuery("select q from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asPage(0, 2); + + assertThat(result.getTotalElements()).isEqualTo(0); + assertThat(result.getContent()).hasSize(0); + } + + @Test + void givenQueryWithGroupBy_shouldPass() { + createTestModel("A"); + createTestModel("B"); + createTestModel("A"); + createTestModel("C"); + + var query = entityManager.createQuery( + "select count(q.email), q.name from QueryTransformerTestModel q group by q.name order by q.name desc" + ); + + var resultPage0 = QueryTransformer + .of(entityManager, TestModelCount.class) + .withQuery(query) + .asPage(0, 2); + + // We have 3 groups: A, B, and C + assertThat(resultPage0.getTotalElements()).isEqualTo(3); + assertThat(resultPage0.getTotalPages()).isEqualTo(2); + assertThat(resultPage0.getContent()).hasSize(2); + + var sameQuery = entityManager.createQuery( + "select count(q.email), q.name from QueryTransformerTestModel q group by q.name order by q.name desc" + ); + + var resultPage1 = QueryTransformer + .of(entityManager, TestModelCount.class) + .withQuery(sameQuery) + .asPage(1, 2); + + assertThat(resultPage1.getTotalElements()).isEqualTo(3); + assertThat(resultPage1.getTotalPages()).isEqualTo(2); + assertThat(resultPage1.getContent()).hasSize(1); + } } - - @Test - void givenQuery_asList_wrongTargetClass_shouldFail() { - createTestModel("abc"); - - var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); - - assertThrows( - TransformerRuntimeException.class, - () -> QueryTransformer - .of(entityManager, WrongContainer.class) - .withQuery(query) - .asList() - ); - } - - protected record UserCount( + protected record TestModelCount( long count, String familyName ) { } - protected record UserContainer( + protected record TestModelContainer( QueryTransformerTestModel testModel, String someText ) { } - protected record WrongContainer( Long someNumber, String someText diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java index fadff06..d116962 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java @@ -2,6 +2,8 @@ import it.aboutbits.springboot.toolbox.autoconfiguration.persistence.AutoRegisteredJavaType; import it.aboutbits.springboot.toolbox.persistence.javatype.base.WrappedLongJavaType; +import it.aboutbits.springboot.toolbox.persistence.javatype.impl.javatype.WrapScaledBigDecimalJavaType; +import it.aboutbits.springboot.toolbox.persistence.javatype.impl.type.WrapScaledBigDecimal; import it.aboutbits.springboot.toolbox.type.identity.EntityId; import it.aboutbits.springboot.toolbox.type.identity.Identified; import jakarta.persistence.Entity; @@ -33,6 +35,10 @@ public class QueryTransformerTestModel implements Identified { diff --git a/src/test/resources/db/changelog/2024-09-26-create-query-transformer-testing-table.yml b/src/test/resources/db/changelog/2024-09-26-create-query-transformer-testing-table.yml index f05612b..57b8368 100644 --- a/src/test/resources/db/changelog/2024-09-26-create-query-transformer-testing-table.yml +++ b/src/test/resources/db/changelog/2024-09-26-create-query-transformer-testing-table.yml @@ -1,6 +1,6 @@ databaseChangeLog: - changeSet: - author: Andreas Hufler + author: Peter Moser id: 2024-09-26-create-query-transformer-testing-table changes: - createTable: @@ -19,3 +19,6 @@ databaseChangeLog: - column: name: email type: text + - column: + name: scaled_big_decimal_value + type: double From c6eca49bb7587e2d147501891604ae445745c089 Mon Sep 17 00:00:00 2001 From: Peter Moser Date: Thu, 26 Sep 2024 15:43:09 +0200 Subject: [PATCH 3/8] add custom java type test --- .../transformer/QueryTransformerTest.java | 28 +++++++++++++++++-- .../impl/jpa/QueryTransformerTestModel.java | 6 ++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java index 4ee993b..57a71b1 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java @@ -3,6 +3,7 @@ import it.aboutbits.springboot.toolbox.persistence.transformer.impl.jpa.QueryTransformerTestModel; import it.aboutbits.springboot.toolbox.persistence.transformer.impl.jpa.QueryTransformerTestModelRepository; import it.aboutbits.springboot.toolbox.support.ApplicationTest; +import it.aboutbits.springboot.toolbox.type.ScaledBigDecimal; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityNotFoundException; import org.junit.jupiter.api.Nested; @@ -52,6 +53,24 @@ void givenQueryWithOneResult_shouldPass() { assertThat(result.get().testModel.getName()).isEqualTo("abc"); } + @Test + void givenQueryWithOneResult_customJavaType_shouldPass() { + var testModel = createTestModel("abc", "info@aboutbits.it", ScaledBigDecimal.valueOf(3.14)); + + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + var result = QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asSingleResult(); + + assertThat(result).isPresent(); + assertThat(result.get().someText).isEqualTo("xxx"); + assertThat(result.get().testModel.getName()).isEqualTo(testModel.getName()); + assertThat(result.get().testModel.getEmail()).isEqualTo(testModel.getEmail()); + assertThat(result.get().testModel.getScaledBigDecimalValue()).isEqualByComparingTo(testModel.getScaledBigDecimalValue()); + } + @Test void givenQueryWithMultipleResults_shouldFail() { createTestModel("A"); @@ -252,10 +271,15 @@ protected record WrongContainer( ) { } - private QueryTransformerTestModel createTestModel(String name) { + private QueryTransformerTestModel createTestModel(String name, String email, ScaledBigDecimal scaledBigDecimal) { var item = new QueryTransformerTestModel(); item.setName(name); - item.setEmail(null); + item.setEmail(email); + item.setScaledBigDecimalValue(scaledBigDecimal); return repository.save(item); } + + private QueryTransformerTestModel createTestModel(String name) { + return createTestModel(name, null, ScaledBigDecimal.ZERO); + } } diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java index d116962..48fea22 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java @@ -1,9 +1,11 @@ package it.aboutbits.springboot.toolbox.persistence.transformer.impl.jpa; import it.aboutbits.springboot.toolbox.autoconfiguration.persistence.AutoRegisteredJavaType; +import it.aboutbits.springboot.toolbox.persistence.javatype.ScaledBigDecimalJavaType; import it.aboutbits.springboot.toolbox.persistence.javatype.base.WrappedLongJavaType; import it.aboutbits.springboot.toolbox.persistence.javatype.impl.javatype.WrapScaledBigDecimalJavaType; import it.aboutbits.springboot.toolbox.persistence.javatype.impl.type.WrapScaledBigDecimal; +import it.aboutbits.springboot.toolbox.type.ScaledBigDecimal; import it.aboutbits.springboot.toolbox.type.identity.EntityId; import it.aboutbits.springboot.toolbox.type.identity.Identified; import jakarta.persistence.Entity; @@ -36,8 +38,8 @@ public class QueryTransformerTestModel implements Identified Date: Thu, 26 Sep 2024 15:45:37 +0200 Subject: [PATCH 4/8] remove unused code --- .../transformer/impl/jpa/QueryTransformerTestModel.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java index 48fea22..a2b4e8a 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java @@ -3,8 +3,6 @@ import it.aboutbits.springboot.toolbox.autoconfiguration.persistence.AutoRegisteredJavaType; import it.aboutbits.springboot.toolbox.persistence.javatype.ScaledBigDecimalJavaType; import it.aboutbits.springboot.toolbox.persistence.javatype.base.WrappedLongJavaType; -import it.aboutbits.springboot.toolbox.persistence.javatype.impl.javatype.WrapScaledBigDecimalJavaType; -import it.aboutbits.springboot.toolbox.persistence.javatype.impl.type.WrapScaledBigDecimal; import it.aboutbits.springboot.toolbox.type.ScaledBigDecimal; import it.aboutbits.springboot.toolbox.type.identity.EntityId; import it.aboutbits.springboot.toolbox.type.identity.Identified; From de74f0eeeb1eb0f0cd8a9663453ffd849f5bffb0 Mon Sep 17 00:00:00 2001 From: Peter Moser Date: Fri, 27 Sep 2024 14:10:23 +0200 Subject: [PATCH 5/8] add wrapped values and primitive support to tuple transformer --- .../it/aboutbits/springboot/toolbox/.gitkeep | 0 .../transformer/QueryTransformer.java | 38 +++-- .../transformer/TupleTransformer.java | 160 +++++++++--------- .../transformer/QueryTransformerTest.java | 87 ++++++++++ .../transformer/TupleTransformerTest.java | 53 ++++-- 5 files changed, 232 insertions(+), 106 deletions(-) delete mode 100644 src/main/java/it/aboutbits/springboot/toolbox/.gitkeep diff --git a/src/main/java/it/aboutbits/springboot/toolbox/.gitkeep b/src/main/java/it/aboutbits/springboot/toolbox/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java index 92a624b..ee8ff35 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java @@ -4,6 +4,7 @@ import jakarta.persistence.EntityNotFoundException; import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; +import lombok.SneakyThrows; import org.hibernate.query.NativeQuery; import org.hibernate.transform.ResultTransformer; import org.springframework.data.domain.Page; @@ -13,28 +14,31 @@ import java.util.List; import java.util.Optional; -@SuppressWarnings({"rawtypes"}) +@SuppressWarnings("rawtypes") public final class QueryTransformer { private final EntityManager entityManager; private final TupleTransformer tupleTransformer; private org.hibernate.query.Query unwrappedQuery; + private Query query; private boolean isNative = false; - private QueryTransformer(final EntityManager entityManager, final Class outputClass) { + private QueryTransformer(EntityManager entityManager, Class outputClass) { this.entityManager = entityManager; this.tupleTransformer = new TupleTransformer<>(outputClass); + } - public static QueryTransformer of(final EntityManager entityManager, final Class outputClass) { + public static QueryTransformer of(EntityManager entityManager, Class outputClass) { return new QueryTransformer<>(entityManager, outputClass); } - public QueryTransformer withQuery(final Query query) { + public QueryTransformer withQuery(Query query) { if (query instanceof NativeQuery) { this.isNative = true; } this.unwrappedQuery = query.unwrap(org.hibernate.query.Query.class); + this.query = query; return this; } @@ -42,7 +46,7 @@ public Page asPage(Pageable pageable) { return asPage(pageable.getPageNumber(), pageable.getPageSize()); } - public Page asPage(final int pageNumber, final int pageSize) { + public Page asPage(int pageNumber, int pageSize) { return isNative ? asPageNativeQuery(pageNumber, pageSize) : asPageQuery(pageNumber, pageSize); } @@ -58,7 +62,7 @@ public Optional asSingleResult() { if (result.size() > 1) { throw new IllegalStateException("Single result query returned multiple results!"); } - return Optional.of(result.get(0)); + return Optional.of(result.getFirst()); } public T asSingleResultOrFail() { @@ -67,7 +71,7 @@ public T asSingleResultOrFail() { } @SuppressWarnings({"deprecation", "unchecked"}) - private List asList(final Integer pageNumber, final Integer pageSize) { + private List asList(Integer pageNumber, Integer pageSize) { unwrappedQuery.setResultTransformer( (ResultTransformer) (objects, aliases) -> tupleTransformer.transform(objects) ); @@ -81,14 +85,16 @@ private List asList(final Integer pageNumber, final Integer pageSize) { return unwrappedQuery.getResultList(); } - private Page asPageQuery(final int pageNumber, final int pageSize) { + @SneakyThrows + private Page asPageQuery(int pageNumber, int pageSize) { var selectPattern = "(?i)select.*?[ \\t]*from "; var queryString = unwrappedQuery.getQueryString().trim().replaceAll("\\R", " "); var countQueryString = queryString.replaceFirst(selectPattern, "select count(*) from "); countQueryString = countQueryString.replaceAll("(?i)\\s+order\\s+by\\s+.*$", ""); if (queryString.toLowerCase().contains("select distinct")) { - throw new IllegalStateException("Pagination is not possible, if SELECT DISTINCT is present"); + throw new IllegalStateException( + "Pagination is not possible, if SELECT DISTINCT is present. Remove DISTINCT and use GROUP BY instead!"); } if (countQueryString.equals(queryString)) { @@ -109,11 +115,13 @@ private Page asPageQuery(final int pageNumber, final int pageSize) { return new PageImpl<>(content, Pageable.ofSize(pageSize).withPage(pageNumber), count); } - private Page asPageNativeQuery(final int pageNumber, final int pageSize) { + private Page asPageNativeQuery(int pageNumber, int pageSize) { var queryString = unwrappedQuery.getQueryString().trim().replaceAll("\\R", " "); var countQueryString = "select count(*) from (" + queryString + ") as count"; var parameters = unwrappedQuery.getParameters(); - var countQuery = isNative ? entityManager.createNativeQuery(countQueryString, Long.class) : entityManager.createQuery(countQueryString, Long.class); + var countQuery = isNative + ? entityManager.createNativeQuery(countQueryString, Long.class) + : entityManager.createQuery(countQueryString, Long.class); for (var parameter : parameters) { var value = unwrappedQuery.getParameterValue(parameter.getPosition()); countQuery.setParameter(parameter.getPosition(), value); @@ -129,7 +137,7 @@ private Page asPageNativeQuery(final int pageNumber, final int pageSize) { * So, if we find a "group by" inside the query string we just count the groups and do not sum the count within * them. */ - private static long getCount(final TypedQuery countQuery, final String queryString) { + private static long getCount(TypedQuery countQuery, String queryString) { var countQueryResults = countQuery.getResultList(); if (countQueryResults == null || countQueryResults.isEmpty()) { return 0L; @@ -141,15 +149,15 @@ private static long getCount(final TypedQuery countQuery, final String que } // Non-grouping query: return the first element, which is the result of count(*) - return countQueryResults.get(0); + return countQueryResults.getFirst(); } - private static long getCount(final Query countQuery) { + private static long getCount(Query countQuery) { var countQueryResults = countQuery.getResultList(); if (countQueryResults == null || countQueryResults.isEmpty()) { return 0L; } // Non-grouping query: return the first element, which is the result of count(*) - return (long) countQueryResults.get(0); + return (long) countQueryResults.getFirst(); } } diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java index 66bdf85..58bf307 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java @@ -1,14 +1,12 @@ package it.aboutbits.springboot.toolbox.persistence.transformer; +import it.aboutbits.springboot.toolbox.reflection.util.RecordReflectionUtil; import it.aboutbits.springboot.toolbox.type.CustomType; -import it.aboutbits.springboot.toolbox.type.ScaledBigDecimal; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Modifier; -import java.math.BigDecimal; -import java.math.BigInteger; import java.time.Instant; import java.time.OffsetDateTime; import java.time.ZoneId; @@ -16,40 +14,78 @@ @SuppressWarnings("rawtypes") public class TupleTransformer { - private final Class outputClass; - private final Constructor outputClassConstructor; - private final Class[] outputClassFieldClasses; + private Constructor outputClassConstructor = null; + private Class[] outputClassFieldClasses = null; + + private final Mode mode; - public TupleTransformer(final Class outputClass) { + private enum Mode { + PRIMITIVE, // Real Java primitives or their wrapped counterpart (ex., long and Long) + WRAPPED, // Record with a single wrapped value (CustomType as for example Iban) + TUPLE // Complex tuples with more than one value + } + + public TupleTransformer(Class outputClass) { this.outputClass = outputClass; // Find all fields and their types inside the result class // Ignore constants, because they will not be used as constructor parameters - outputClassFieldClasses = Arrays - .stream(outputClass.getDeclaredFields()) - .filter(field -> !Modifier.isStatic(field.getModifiers())) - .map(Field::getType) - .toArray(Class[]::new); - - // Find the all-args-constructor inside the result class - try { - outputClassConstructor = outputClass.getDeclaredConstructor(outputClassFieldClasses); - outputClassConstructor.setAccessible(true); - } catch (NoSuchMethodException exception) { - throw new TransformerRuntimeException( - String.format( - "Query transformation: Could not find a valid constructor in target class %s", - outputClass.getName() - ), - exception - ); + if (outputClass.isPrimitive() || isSimpleType(outputClass)) { + mode = Mode.PRIMITIVE; + } else if (CustomType.class.isAssignableFrom(outputClass)) { + mode = Mode.WRAPPED; + } else { + mode = Mode.TUPLE; + + outputClassFieldClasses = Arrays + .stream(outputClass.getDeclaredFields()) + .filter(field -> !Modifier.isStatic(field.getModifiers())) + .map(Field::getType) + .toArray(Class[]::new); + + // Find the all-args-constructor inside the result class + try { + outputClassConstructor = outputClass.getDeclaredConstructor(outputClassFieldClasses); + outputClassConstructor.setAccessible(true); + } catch (NoSuchMethodException exception) { + throw new TransformerRuntimeException( + String.format( + "Query transformation: Could not find a valid constructor in target class %s", + outputClass.getName() + ), + exception + ); + } } } + private static boolean isSimpleType(Class outputClass) { + return String.class.isAssignableFrom(outputClass) + || Float.class.isAssignableFrom(outputClass) + || Double.class.isAssignableFrom(outputClass) + || Short.class.isAssignableFrom(outputClass) + || Integer.class.isAssignableFrom(outputClass) + || Long.class.isAssignableFrom(outputClass) + || Boolean.class.isAssignableFrom(outputClass); + } + @SuppressWarnings("unchecked") - public T transform(final Object[] objects) { + public T transform(Object[] objects) { try { + if (Mode.PRIMITIVE.equals(mode)) { + if (objects.length != 1) { + throw new TransformerRuntimeException("PRIMITIVE mode does not support multiple values!"); + } + return (T) objects[0]; + } + + if (Mode.WRAPPED.equals(mode)) { + if (objects.length != 1) { + throw new TransformerRuntimeException("WRAPPED mode does not support multiple values!"); + } + return (T) toCustomType(objects[0], (Class>) outputClass); + } // If we have a single entry in the result, and that entry matches the desired result class // we can just give it back, no casting, nor type-checking needed. We can just unbox it and @@ -95,12 +131,6 @@ public T transform(final Object[] objects) { continue; } - // Converter: to WrappedValue - if (CustomType.class.isAssignableFrom(outputClassFieldClasses[i])) { - objects[i] = toWrappedValue(objects[i], outputClassFieldClasses[i]); - continue; - } - // Converter: Instant to OffsetDateTime if (objects[i] instanceof Instant && outputClassFieldClasses[i].isAssignableFrom(OffsetDateTime.class)) { objects[i] = OffsetDateTime.ofInstant( @@ -110,6 +140,12 @@ public T transform(final Object[] objects) { continue; } + // Converter: to Records that wrap exactly one value (CustomType) + if (CustomType.class.isAssignableFrom(outputClassFieldClasses[i])) { + objects[i] = toCustomType(objects[i], outputClassFieldClasses[i]); + continue; + } + // Non-matching classes in fields. No converter found... throw new UnsupportedOperationException( String.format( @@ -122,8 +158,13 @@ public T transform(final Object[] objects) { return outputClassConstructor.newInstance(objects); - } catch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException - | UnsupportedOperationException exception) { + } catch ( + InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException + | UnsupportedOperationException exception + ) { throw new TransformerRuntimeException( String.format( "Query transformation: Given database record cannot be converted into target class %s", @@ -134,55 +175,12 @@ public T transform(final Object[] objects) { } } - @SuppressWarnings("unchecked") - private > X toWrappedValue( + private > X toCustomType( Object actualValue, Class targetType - ) throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException { - // types with a preferred constructor - if (targetType.isAssignableFrom(ScaledBigDecimal.class)) { - var constructor = targetType.getDeclaredConstructor(Double.class); - var value = (Double) actualValue; - return constructor.newInstance(value); - } + ) throws InvocationTargetException, InstantiationException, IllegalAccessException { + var constructor = RecordReflectionUtil.getConstructorForType(targetType, actualValue.getClass()); - // types using first suitable constructor - Constructor[] constructors = targetType.getDeclaredConstructors(); - - for (Constructor constructor : constructors) { - Class[] parameterTypes = constructor.getParameterTypes(); - if (parameterTypes.length == 1) { - if (Number.class.isAssignableFrom(parameterTypes[0])) { - if (Long.class.equals(parameterTypes[0])) { - var val = (Long) actualValue; - return (X) constructor.newInstance(val); - } else if (Integer.class.equals(parameterTypes[0])) { - var val = (Integer) actualValue; - return (X) constructor.newInstance(val); - } else if (Double.class.equals(parameterTypes[0])) { - var val = (Double) actualValue; - return (X) constructor.newInstance(val); - } else if (Float.class.equals(parameterTypes[0])) { - var val = (Float) actualValue; - return (X) constructor.newInstance(val); - } else if (BigInteger.class.equals(parameterTypes[0])) { - var val = BigInteger.valueOf((Long) actualValue); - return (X) constructor.newInstance(val); - } else if (BigDecimal.class.equals(parameterTypes[0])) { - var val = BigDecimal.valueOf((Double) actualValue); - return (X) constructor.newInstance(val); - } else { - throw new IllegalArgumentException("Unsupported number type"); - } - } else if (String.class.equals(parameterTypes[0])) { - var val = (String) actualValue; - return (X) constructor.newInstance(val); - } else if (Boolean.class.equals(parameterTypes[0])) { - var val = (Boolean) actualValue; - return (X) constructor.newInstance(val); - } // Add more types as needed - } - } - throw new IllegalArgumentException(targetType.getSimpleName() + " does not have a suitable single-value constructor"); + return constructor.newInstance(actualValue); } } diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java index 57a71b1..49d148c 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java @@ -37,6 +37,43 @@ void givenQueryWithNoResults_shouldPass() { assertThat(result).isNotPresent(); } + @Test + void givenQueryWithPrimitiveResult_shouldPass() { + var query = entityManager.createQuery("select 'abc'"); + + var result = QueryTransformer + .of(entityManager, String.class) + .withQuery(query) + .asSingleResult(); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo("abc"); + + query = entityManager.createQuery("select 4.44"); + + var result2 = QueryTransformer + .of(entityManager, Double.class) + .withQuery(query) + .asSingleResult(); + + assertThat(result2).isPresent(); + assertThat(result2.get()).isEqualTo(4.44); + } + + @Test + void givenQueryWithPrimitiveCustomTypedResult_shouldPass() { + var query = entityManager.createQuery("select 3.14"); + + var result = QueryTransformer + .of(entityManager, ScaledBigDecimal.class) + .withQuery(query) + .asSingleResult(); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualByComparingTo(ScaledBigDecimal.valueOf(3.14)); + } + + @Test void givenQueryWithOneResult_shouldPass() { createTestModel("abc"); @@ -251,6 +288,51 @@ void givenQueryWithGroupBy_shouldPass() { assertThat(resultPage1.getTotalPages()).isEqualTo(2); assertThat(resultPage1.getContent()).hasSize(1); } + + @Test + void givenVariousQueries_shouldPassReturningTheRightTotalCount() { + createTestModel("A"); + createTestModel("B"); + createTestModel("A"); + createTestModel("A"); + + var query = entityManager.createQuery( + "select q.name from QueryTransformerTestModel q group by q.name order by q.name desc" + ); + + var resultPage = QueryTransformer + .of(entityManager, TestModelNameOnly.class) + .withQuery(query) + .asPage(0, 2); + + // We have 2 groups: A, and B + assertThat(resultPage.getTotalElements()).isEqualTo(2); + + // TEST: No grouping + query = entityManager.createQuery( + "select q.name from QueryTransformerTestModel q" + ); + + resultPage = QueryTransformer + .of(entityManager, TestModelNameOnly.class) + .withQuery(query) + .asPage(0, 2); + + assertThat(resultPage.getTotalElements()).isEqualTo(4); + } + + @Test + void givenQueryWithSelectDistinct_shouldFail() { + var query = entityManager.createQuery("select distinct q, 'xxx' from QueryTransformerTestModel q"); + + assertThrows( + IllegalStateException.class, + () -> QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asPage(1, 2) + ); + } } protected record TestModelCount( @@ -259,6 +341,11 @@ protected record TestModelCount( ) { } + protected record TestModelNameOnly( + String name + ) { + } + protected record TestModelContainer( QueryTransformerTestModel testModel, String someText diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java index 45c9f85..2965c33 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java @@ -1,5 +1,8 @@ package it.aboutbits.springboot.toolbox.persistence.transformer; +import it.aboutbits.springboot.toolbox.type.EmailAddress; +import it.aboutbits.springboot.toolbox.type.Iban; +import it.aboutbits.springboot.toolbox.type.ScaledBigDecimal; import lombok.Data; import org.junit.jupiter.api.Test; @@ -16,7 +19,7 @@ void createObjectWithPrimitiveFields_givenPrimitives_shouldPass() { var tupleTransformer = new TupleTransformer<>(DataClassWithPrimitives.class); // when - var result = tupleTransformer.transform(new Object[] {7L, 12, true}); + var result = tupleTransformer.transform(new Object[]{7L, 12, true}); // then assertThat(result.longField).isEqualTo(7L); @@ -24,6 +27,26 @@ void createObjectWithPrimitiveFields_givenPrimitives_shouldPass() { assertThat(result.booleanField).isTrue(); } + @Test + void createObjectWithCustomTypedFields_shouldPass() { + // given + var tupleTransformer = new TupleTransformer<>(DataRecordWithCustomType.class); + + // when + var result = tupleTransformer.transform( + new Object[]{ + 3.14d, + "IT27S0300203280975461985512", + "info@aboutbits.it" + } + ); + + // then + assertThat(result.scaledBigDecimal).isEqualByComparingTo(ScaledBigDecimal.valueOf(3.14)); + assertThat(result.emailAddress).isEqualTo(new EmailAddress("info@aboutbits.it")); + assertThat(result.iban).isEqualTo(new Iban("IT27S0300203280975461985512")); + } + @Test void createObjectWithPrimitiveFields_givenBoxedObjects_shouldPass() { // given @@ -31,7 +54,7 @@ void createObjectWithPrimitiveFields_givenBoxedObjects_shouldPass() { var l = Long.valueOf(7); var i = Integer.valueOf(12); var b = Boolean.valueOf(true); - var objectsUnderTest = new Object[] {l, i, b}; + var objectsUnderTest = new Object[]{l, i, b}; // when var result = tupleTransformer.transform(objectsUnderTest); @@ -49,7 +72,7 @@ void createRecord_givenMixedObjects_shouldPass() { var tupleTransformer = new TupleTransformer<>(DataRecord.class); var l = Long.valueOf(7); var b = Boolean.valueOf(true); - var objectsUnderTest = new Object[] {l, b, "String123", false, SomeEnum.ENUM_1}; + var objectsUnderTest = new Object[]{l, b, "String123", false, SomeEnum.ENUM_1}; // when var result = tupleTransformer.transform(objectsUnderTest); @@ -69,7 +92,7 @@ void createRecord_givenMixedObjects_enumValueAsString_shouldPass() { var tupleTransformer = new TupleTransformer<>(DataRecord.class); var l = Long.valueOf(7); var b = Boolean.valueOf(true); - var objectsUnderTest = new Object[] {l, b, "String123", false, "ENUM_1"}; + var objectsUnderTest = new Object[]{l, b, "String123", false, "ENUM_1"}; // when var result = tupleTransformer.transform(objectsUnderTest); @@ -88,7 +111,7 @@ void createRecordInsideAClass_givenMixedObjects_shouldPass() { // given var tupleTransformer = new TupleTransformer<>(DataRecordParent.class); var rec = new DataRecord(7L, true, "String123", false, SomeEnum.ENUM_1); - var objectsUnderTest = new Object[] {rec, 33}; + var objectsUnderTest = new Object[]{rec, 33}; // when var result = tupleTransformer.transform(objectsUnderTest); @@ -107,7 +130,7 @@ void createRecordInsideAClass_givenMixedObjects_shouldPass() { void createRecord_givenMixedObjects_someNullValues_shouldPass() { // given var tupleTransformer = new TupleTransformer<>(DataRecord.class); - var objectsUnderTest = new Object[] {null, null, "String123", false, null}; + var objectsUnderTest = new Object[]{null, null, "String123", false, null}; // when var result = tupleTransformer.transform(objectsUnderTest); @@ -130,7 +153,7 @@ void createRecordInsideAClass_givenMixedObjectsAsList_shouldPass() { var list = new ArrayList<>(); list.add(rec1); list.add(rec2); - var objectsUnderTest = new Object[] {list, 33}; + var objectsUnderTest = new Object[]{list, 33}; // when var result = tupleTransformer.transform(objectsUnderTest); @@ -167,19 +190,29 @@ protected record DataRecord( String string, boolean booleanPrimitive, SomeEnum anEnum - ) { } + ) { + } protected record DataRecordParent( DataRecord dataRecord, long someOtherField - ) { } + ) { + } + protected record DataRecordWithCustomType( + ScaledBigDecimal scaledBigDecimal, + Iban iban, + EmailAddress emailAddress + ) { + } + protected record DataRecordParentWithList( List dataRecords, long someOtherField - ) { } + ) { + } protected enum SomeEnum { ENUM_1, From f4b8a9209e97202911e5d7bfc1e8972aa348a0b8 Mon Sep 17 00:00:00 2001 From: Peter Moser Date: Fri, 27 Sep 2024 14:57:15 +0200 Subject: [PATCH 6/8] add test --- .../transformer/QueryTransformerTest.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java index 49d148c..54a9361 100644 --- a/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java @@ -108,6 +108,29 @@ void givenQueryWithOneResult_customJavaType_shouldPass() { assertThat(result.get().testModel.getScaledBigDecimalValue()).isEqualByComparingTo(testModel.getScaledBigDecimalValue()); } + @Test + void givenQueryWithNullableParameter_shouldPass() { + createTestModel("abc", "info@aboutbits.it", ScaledBigDecimal.valueOf(3.14)); + + // Testing https://linear.app/aboutbits/issue/AB-217/be-querytransformer-function-lowerbytea-does-not-exist + var query = entityManager.createQuery( + // @formatter:off + """ + select q + from QueryTransformerTestModel q + where :name is null or q.name ilike '%' || cast(:name as string) || '%' + """ + // @formatter:on + ).setParameter("name", null); + + var result = QueryTransformer + .of(entityManager, QueryTransformerTestModel.class) + .withQuery(query) + .asSingleResult(); + + assertThat(result).isPresent(); + } + @Test void givenQueryWithMultipleResults_shouldFail() { createTestModel("A"); From 389cf6916ee4afa3347dae06284930ca2c165b95 Mon Sep 17 00:00:00 2001 From: Peter Moser Date: Fri, 27 Sep 2024 16:05:00 +0200 Subject: [PATCH 7/8] add missing simple types --- .../toolbox/persistence/transformer/QueryTransformer.java | 2 -- .../toolbox/persistence/transformer/TupleTransformer.java | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java index ee8ff35..037a91a 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java @@ -20,7 +20,6 @@ public final class QueryTransformer { private final EntityManager entityManager; private final TupleTransformer tupleTransformer; private org.hibernate.query.Query unwrappedQuery; - private Query query; private boolean isNative = false; private QueryTransformer(EntityManager entityManager, Class outputClass) { @@ -38,7 +37,6 @@ public QueryTransformer withQuery(Query query) { this.isNative = true; } this.unwrappedQuery = query.unwrap(org.hibernate.query.Query.class); - this.query = query; return this; } diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java index 58bf307..21b92d0 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java @@ -67,6 +67,8 @@ private static boolean isSimpleType(Class outputClass) { || Short.class.isAssignableFrom(outputClass) || Integer.class.isAssignableFrom(outputClass) || Long.class.isAssignableFrom(outputClass) + || Character.class.isAssignableFrom(outputClass) + || Byte.class.isAssignableFrom(outputClass) || Boolean.class.isAssignableFrom(outputClass); } From cc4d53545865b05c84adda297ed6eb62a15eadae Mon Sep 17 00:00:00 2001 From: Andreas Hufler Date: Fri, 27 Sep 2024 16:18:16 +0200 Subject: [PATCH 8/8] fix raw types --- .../transformer/QueryTransformer.java | 9 ++--- .../transformer/TupleTransformer.java | 35 +++++++++---------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java index 037a91a..1e6994f 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java @@ -4,7 +4,6 @@ import jakarta.persistence.EntityNotFoundException; import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; -import lombok.SneakyThrows; import org.hibernate.query.NativeQuery; import org.hibernate.transform.ResultTransformer; import org.springframework.data.domain.Page; @@ -14,12 +13,11 @@ import java.util.List; import java.util.Optional; -@SuppressWarnings("rawtypes") public final class QueryTransformer { private final EntityManager entityManager; private final TupleTransformer tupleTransformer; - private org.hibernate.query.Query unwrappedQuery; + private org.hibernate.query.Query unwrappedQuery; private boolean isNative = false; private QueryTransformer(EntityManager entityManager, Class outputClass) { @@ -71,7 +69,7 @@ public T asSingleResultOrFail() { @SuppressWarnings({"deprecation", "unchecked"}) private List asList(Integer pageNumber, Integer pageSize) { unwrappedQuery.setResultTransformer( - (ResultTransformer) (objects, aliases) -> tupleTransformer.transform(objects) + (ResultTransformer) (objects, aliases) -> tupleTransformer.transform(objects) ); if (pageSize != null && pageNumber != null) { @@ -80,10 +78,9 @@ private List asList(Integer pageNumber, Integer pageSize) { .setFirstResult(pageSize * pageNumber); } - return unwrappedQuery.getResultList(); + return (List) unwrappedQuery.getResultList(); } - @SneakyThrows private Page asPageQuery(int pageNumber, int pageSize) { var selectPattern = "(?i)select.*?[ \\t]*from "; var queryString = unwrappedQuery.getQueryString().trim().replaceAll("\\R", " "); diff --git a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java index 21b92d0..865993a 100644 --- a/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java @@ -12,11 +12,10 @@ import java.time.ZoneId; import java.util.Arrays; -@SuppressWarnings("rawtypes") public class TupleTransformer { private final Class outputClass; private Constructor outputClassConstructor = null; - private Class[] outputClassFieldClasses = null; + private Class[] outputClassFieldClasses = null; private final Mode mode; @@ -60,18 +59,6 @@ public TupleTransformer(Class outputClass) { } } - private static boolean isSimpleType(Class outputClass) { - return String.class.isAssignableFrom(outputClass) - || Float.class.isAssignableFrom(outputClass) - || Double.class.isAssignableFrom(outputClass) - || Short.class.isAssignableFrom(outputClass) - || Integer.class.isAssignableFrom(outputClass) - || Long.class.isAssignableFrom(outputClass) - || Character.class.isAssignableFrom(outputClass) - || Byte.class.isAssignableFrom(outputClass) - || Boolean.class.isAssignableFrom(outputClass); - } - @SuppressWarnings("unchecked") public T transform(Object[] objects) { try { @@ -134,9 +121,9 @@ public T transform(Object[] objects) { } // Converter: Instant to OffsetDateTime - if (objects[i] instanceof Instant && outputClassFieldClasses[i].isAssignableFrom(OffsetDateTime.class)) { + if (objects[i] instanceof Instant instant && outputClassFieldClasses[i].isAssignableFrom(OffsetDateTime.class)) { objects[i] = OffsetDateTime.ofInstant( - (Instant) objects[i], + instant, ZoneId.systemDefault() ); continue; @@ -144,7 +131,7 @@ public T transform(Object[] objects) { // Converter: to Records that wrap exactly one value (CustomType) if (CustomType.class.isAssignableFrom(outputClassFieldClasses[i])) { - objects[i] = toCustomType(objects[i], outputClassFieldClasses[i]); + objects[i] = toCustomType(objects[i], (Class>) outputClassFieldClasses[i]); continue; } @@ -177,7 +164,19 @@ public T transform(Object[] objects) { } } - private > X toCustomType( + private static boolean isSimpleType(Class outputClass) { + return String.class.isAssignableFrom(outputClass) + || Float.class.isAssignableFrom(outputClass) + || Double.class.isAssignableFrom(outputClass) + || Short.class.isAssignableFrom(outputClass) + || Integer.class.isAssignableFrom(outputClass) + || Long.class.isAssignableFrom(outputClass) + || Character.class.isAssignableFrom(outputClass) + || Byte.class.isAssignableFrom(outputClass) + || Boolean.class.isAssignableFrom(outputClass); + } + + private static > X toCustomType( Object actualValue, Class targetType ) throws InvocationTargetException, InstantiationException, IllegalAccessException {