Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
Original file line number Diff line number Diff line change
@@ -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<T> {

private final EntityManager entityManager;
private final TupleTransformer<T> tupleTransformer;
private org.hibernate.query.Query<?> unwrappedQuery;
private boolean isNative = false;

private QueryTransformer(EntityManager entityManager, Class<T> outputClass) {
this.entityManager = entityManager;
this.tupleTransformer = new TupleTransformer<>(outputClass);

}

public static <T> QueryTransformer<T> of(EntityManager entityManager, Class<T> outputClass) {
return new QueryTransformer<>(entityManager, outputClass);
}

public QueryTransformer<T> withQuery(Query query) {
if (query instanceof NativeQuery<?>) {
this.isNative = true;
}
this.unwrappedQuery = query.unwrap(org.hibernate.query.Query.class);
return this;
}

public Page<T> asPage(Pageable pageable) {
return asPage(pageable.getPageNumber(), pageable.getPageSize());
}

public Page<T> asPage(int pageNumber, int pageSize) {
return isNative ? asPageNativeQuery(pageNumber, pageSize) : asPageQuery(pageNumber, pageSize);
}

public List<T> asList() {
return asList(null, null);
}

public Optional<T> 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<T> 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<T>) unwrappedQuery.getResultList();
}

private Page<T> 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<T> 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<Long> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<T> {
private final Class<T> outputClass;
private Constructor<T> 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<T> 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<CustomType<?>>) 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<? extends CustomType<?>>) 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 <T> boolean isSimpleType(Class<T> 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 extends CustomType<?>> X toCustomType(
Object actualValue,
Class<X> targetType
) throws InvocationTargetException, InstantiationException, IllegalAccessException {
var constructor = RecordReflectionUtil.getConstructorForType(targetType, actualValue.getClass());

return constructor.newInstance(actualValue);
}
}
Loading