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 new file mode 100644 index 0000000..1e6994f --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformer.java @@ -0,0 +1,158 @@ +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; + +public final class QueryTransformer { + + private final EntityManager entityManager; + private final TupleTransformer tupleTransformer; + private org.hibernate.query.Query unwrappedQuery; + private boolean isNative = false; + + private QueryTransformer(EntityManager entityManager, Class outputClass) { + this.entityManager = entityManager; + this.tupleTransformer = new TupleTransformer<>(outputClass); + + } + + public static QueryTransformer of(EntityManager entityManager, Class outputClass) { + return new QueryTransformer<>(entityManager, outputClass); + } + + public QueryTransformer withQuery(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(int pageNumber, 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.getFirst()); + } + + public T asSingleResultOrFail() { + return asSingleResult() + .orElseThrow(EntityNotFoundException::new); + } + + @SuppressWarnings({"deprecation", "unchecked"}) + private List asList(Integer pageNumber, Integer pageSize) { + unwrappedQuery.setResultTransformer( + (ResultTransformer) (objects, aliases) -> tupleTransformer.transform(objects) + ); + + if (pageSize != null && pageNumber != null) { + unwrappedQuery + .setMaxResults(pageSize) + .setFirstResult(pageSize * pageNumber); + } + + return (List) unwrappedQuery.getResultList(); + } + + 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. Remove DISTINCT and use GROUP BY instead!"); + } + + 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(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); + 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(TypedQuery countQuery, 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.getFirst(); + } + + 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.getFirst(); + } +} 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..865993a --- /dev/null +++ b/src/main/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformer.java @@ -0,0 +1,187 @@ +package it.aboutbits.springboot.toolbox.persistence.transformer; + +import it.aboutbits.springboot.toolbox.reflection.util.RecordReflectionUtil; +import it.aboutbits.springboot.toolbox.type.CustomType; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.Arrays; + +public class TupleTransformer { + private final Class outputClass; + private Constructor outputClassConstructor = null; + private Class[] outputClassFieldClasses = null; + + private final Mode mode; + + 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 + 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 + ); + } + } + } + + @SuppressWarnings("unchecked") + 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 + // 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: Instant to OffsetDateTime + if (objects[i] instanceof Instant instant && outputClassFieldClasses[i].isAssignableFrom(OffsetDateTime.class)) { + objects[i] = OffsetDateTime.ofInstant( + instant, + ZoneId.systemDefault() + ); + continue; + } + + // Converter: to Records that wrap exactly one value (CustomType) + if (CustomType.class.isAssignableFrom(outputClassFieldClasses[i])) { + objects[i] = toCustomType(objects[i], (Class>) outputClassFieldClasses[i]); + 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 + ); + } + } + + 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 { + var constructor = RecordReflectionUtil.getConstructorForType(targetType, actualValue.getClass()); + + 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 new file mode 100644 index 0000000..54a9361 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/QueryTransformerTest.java @@ -0,0 +1,395 @@ +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 it.aboutbits.springboot.toolbox.type.ScaledBigDecimal; +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; + +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; + + @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 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"); + + 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 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 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"); + 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() + ); + } + } + + @Nested + class AsSingleResultOrFail { + + @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) + .asSingleResultOrFail(); + + assertThat(result.someText).isEqualTo("xxx"); + } + + @Test + void givenQueryWithOneResult_shouldFail() { + var query = entityManager.createQuery("select q, 'xxx' from QueryTransformerTestModel q"); + + assertThrows(EntityNotFoundException.class, () -> QueryTransformer + .of(entityManager, TestModelContainer.class) + .withQuery(query) + .asSingleResultOrFail()); + } + } + + @Nested + class AsList { + + @Test + void givenQuery_singleUnboxedEntityResult_shouldPass() { + var testModel = 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(testModel.getId()); + assertThat(result.getFirst().getName()).isEqualTo(testModel.getName()); + } + + + @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) + .asList(); + + assertThat(result.getFirst().testModel().getId()).isEqualTo(testModel.getId()); + assertThat(result.getFirst().testModel().getName()).isEqualTo(testModel.getName()); + assertThat(result.getFirst().someText).isEqualTo("xxx"); + } + + @Test + void givenQuery_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() + ); + } + } + + @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 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( + long count, + String familyName + ) { + } + + protected record TestModelNameOnly( + String name + ) { + } + + protected record TestModelContainer( + QueryTransformerTestModel testModel, + String someText + ) { + } + + protected record WrongContainer( + Long someNumber, + String someText + ) { + } + + private QueryTransformerTestModel createTestModel(String name, String email, ScaledBigDecimal scaledBigDecimal) { + var item = new QueryTransformerTestModel(); + item.setName(name); + 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/TupleTransformerTest.java b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java new file mode 100644 index 0000000..2965c33 --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/TupleTransformerTest.java @@ -0,0 +1,221 @@ +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; + +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 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 + 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 DataRecordWithCustomType( + ScaledBigDecimal scaledBigDecimal, + Iban iban, + EmailAddress emailAddress + ) { + } + + 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..a2b4e8a --- /dev/null +++ b/src/test/java/it/aboutbits/springboot/toolbox/persistence/transformer/impl/jpa/QueryTransformerTestModel.java @@ -0,0 +1,57 @@ +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.type.ScaledBigDecimal; +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; + + @SuppressWarnings("JpaAttributeTypeInspection") + @JavaType(ScaledBigDecimalJavaType.class) + private ScaledBigDecimal scaledBigDecimalValue; + + 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..57b8368 --- /dev/null +++ b/src/test/resources/db/changelog/2024-09-26-create-query-transformer-testing-table.yml @@ -0,0 +1,24 @@ +databaseChangeLog: + - changeSet: + author: Peter Moser + 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 + - column: + name: scaled_big_decimal_value + type: double 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