Skip to content

Commit 05363b8

Browse files
committed
add query transformer
1 parent a5e6a9e commit 05363b8

9 files changed

Lines changed: 881 additions & 0 deletions

File tree

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package it.aboutbits.springboot.toolbox.persistence.transformer;
2+
3+
import jakarta.persistence.EntityManager;
4+
import jakarta.persistence.EntityNotFoundException;
5+
import jakarta.persistence.Query;
6+
import jakarta.persistence.TypedQuery;
7+
import org.hibernate.query.NativeQuery;
8+
import org.hibernate.transform.ResultTransformer;
9+
import org.springframework.data.domain.Page;
10+
import org.springframework.data.domain.PageImpl;
11+
import org.springframework.data.domain.Pageable;
12+
13+
import java.util.List;
14+
import java.util.Optional;
15+
16+
@SuppressWarnings({"rawtypes"})
17+
public final class QueryTransformer<T> {
18+
19+
private final EntityManager entityManager;
20+
private final TupleTransformer<T> tupleTransformer;
21+
private org.hibernate.query.Query unwrappedQuery;
22+
private boolean isNative = false;
23+
24+
private QueryTransformer(final EntityManager entityManager, final Class<T> outputClass) {
25+
this.entityManager = entityManager;
26+
this.tupleTransformer = new TupleTransformer<>(outputClass);
27+
}
28+
29+
public static <T> QueryTransformer<T> of(final EntityManager entityManager, final Class<T> outputClass) {
30+
return new QueryTransformer<>(entityManager, outputClass);
31+
}
32+
33+
public QueryTransformer<T> withQuery(final Query query) {
34+
if (query instanceof NativeQuery<?>) {
35+
this.isNative = true;
36+
}
37+
this.unwrappedQuery = query.unwrap(org.hibernate.query.Query.class);
38+
return this;
39+
}
40+
41+
public Page<T> asPage(Pageable pageable) {
42+
return asPage(pageable.getPageNumber(), pageable.getPageSize());
43+
}
44+
45+
public Page<T> asPage(final int pageNumber, final int pageSize) {
46+
return isNative ? asPageNativeQuery(pageNumber, pageSize) : asPageQuery(pageNumber, pageSize);
47+
}
48+
49+
public List<T> asList() {
50+
return asList(null, null);
51+
}
52+
53+
public Optional<T> asSingleResult() {
54+
var result = asList();
55+
if (result.isEmpty()) {
56+
return Optional.empty();
57+
}
58+
if (result.size() > 1) {
59+
throw new IllegalStateException("Single result query returned multiple results!");
60+
}
61+
return Optional.of(result.get(0));
62+
}
63+
64+
public T asSingleResultOrFail() {
65+
return asSingleResult()
66+
.orElseThrow(EntityNotFoundException::new);
67+
}
68+
69+
@SuppressWarnings({"deprecation", "unchecked"})
70+
private List<T> asList(final Integer pageNumber, final Integer pageSize) {
71+
unwrappedQuery.setResultTransformer(
72+
(ResultTransformer) (objects, aliases) -> tupleTransformer.transform(objects)
73+
);
74+
75+
if (pageSize != null && pageNumber != null) {
76+
unwrappedQuery
77+
.setMaxResults(pageSize)
78+
.setFirstResult(pageSize * pageNumber);
79+
}
80+
81+
return unwrappedQuery.getResultList();
82+
}
83+
84+
private Page<T> asPageQuery(final int pageNumber, final int pageSize) {
85+
var selectPattern = "(?i)select.*?[ \\t]*from ";
86+
var queryString = unwrappedQuery.getQueryString().trim().replaceAll("\\R", " ");
87+
var countQueryString = queryString.replaceFirst(selectPattern, "select count(*) from ");
88+
countQueryString = countQueryString.replaceAll("(?i)\\s+order\\s+by\\s+.*$", "");
89+
90+
if (queryString.toLowerCase().contains("select distinct")) {
91+
throw new IllegalStateException("Pagination is not possible, if SELECT DISTINCT is present");
92+
}
93+
94+
if (countQueryString.equals(queryString)) {
95+
throw new IllegalStateException("Unable to find SELECT ... FROM in query string!");
96+
}
97+
98+
var parameters = unwrappedQuery.getParameters();
99+
var countQuery = entityManager.createQuery(countQueryString, Long.class);
100+
for (var parameter : parameters) {
101+
var value = unwrappedQuery.getParameterValue(parameter.getName());
102+
countQuery.setParameter(parameter.getName(), value);
103+
}
104+
105+
var count = getCount(countQuery, queryString);
106+
107+
var content = asList(pageNumber, pageSize);
108+
109+
return new PageImpl<>(content, Pageable.ofSize(pageSize).withPage(pageNumber), count);
110+
}
111+
112+
private Page<T> asPageNativeQuery(final int pageNumber, final int pageSize) {
113+
var queryString = unwrappedQuery.getQueryString().trim().replaceAll("\\R", " ");
114+
var countQueryString = "select count(*) from (" + queryString + ") as count";
115+
var parameters = unwrappedQuery.getParameters();
116+
var countQuery = isNative ? entityManager.createNativeQuery(countQueryString, Long.class) : entityManager.createQuery(countQueryString, Long.class);
117+
for (var parameter : parameters) {
118+
var value = unwrappedQuery.getParameterValue(parameter.getPosition());
119+
countQuery.setParameter(parameter.getPosition(), value);
120+
}
121+
122+
var count = getCount(countQuery);
123+
var content = asList(pageNumber, pageSize);
124+
return new PageImpl<>(content, Pageable.ofSize(pageSize).withPage(pageNumber), count);
125+
}
126+
127+
/**
128+
* A "group by" clause generates a count for each group, counting the members of that group.
129+
* So, if we find a "group by" inside the query string we just count the groups and do not sum the count within
130+
* them.
131+
*/
132+
private static long getCount(final TypedQuery<Long> countQuery, final String queryString) {
133+
var countQueryResults = countQuery.getResultList();
134+
if (countQueryResults == null || countQueryResults.isEmpty()) {
135+
return 0L;
136+
}
137+
138+
// Grouping query: count the groups and do not sum the count within them
139+
if (queryString.toLowerCase().contains("group by")) {
140+
return countQueryResults.size();
141+
}
142+
143+
// Non-grouping query: return the first element, which is the result of count(*)
144+
return countQueryResults.get(0);
145+
}
146+
147+
private static long getCount(final Query countQuery) {
148+
var countQueryResults = countQuery.getResultList();
149+
if (countQueryResults == null || countQueryResults.isEmpty()) {
150+
return 0L;
151+
}
152+
// Non-grouping query: return the first element, which is the result of count(*)
153+
return (long) countQueryResults.get(0);
154+
}
155+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package it.aboutbits.springboot.toolbox.persistence.transformer;
2+
3+
public class TransformerRuntimeException extends RuntimeException {
4+
public TransformerRuntimeException() {
5+
}
6+
7+
public TransformerRuntimeException(final String message) {
8+
super(message);
9+
}
10+
11+
public TransformerRuntimeException(final String message, final Throwable cause) {
12+
super(message, cause);
13+
}
14+
15+
public TransformerRuntimeException(final Throwable cause) {
16+
super(cause);
17+
}
18+
19+
public TransformerRuntimeException(final String message, final Throwable cause, final boolean enableSuppression, final boolean writableStackTrace) {
20+
super(message, cause, enableSuppression, writableStackTrace);
21+
}
22+
}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
package it.aboutbits.springboot.toolbox.persistence.transformer;
2+
3+
import it.aboutbits.springboot.toolbox.type.CustomType;
4+
import it.aboutbits.springboot.toolbox.type.ScaledBigDecimal;
5+
6+
import java.lang.reflect.Constructor;
7+
import java.lang.reflect.Field;
8+
import java.lang.reflect.InvocationTargetException;
9+
import java.lang.reflect.Modifier;
10+
import java.math.BigDecimal;
11+
import java.math.BigInteger;
12+
import java.time.Instant;
13+
import java.time.OffsetDateTime;
14+
import java.time.ZoneId;
15+
import java.util.Arrays;
16+
17+
@SuppressWarnings("rawtypes")
18+
public class TupleTransformer<T> {
19+
20+
private final Class<T> outputClass;
21+
private final Constructor<T> outputClassConstructor;
22+
private final Class[] outputClassFieldClasses;
23+
24+
public TupleTransformer(final Class<T> outputClass) {
25+
this.outputClass = outputClass;
26+
27+
// Find all fields and their types inside the result class
28+
// Ignore constants, because they will not be used as constructor parameters
29+
outputClassFieldClasses = Arrays
30+
.stream(outputClass.getDeclaredFields())
31+
.filter(field -> !Modifier.isStatic(field.getModifiers()))
32+
.map(Field::getType)
33+
.toArray(Class[]::new);
34+
35+
// Find the all-args-constructor inside the result class
36+
try {
37+
outputClassConstructor = outputClass.getDeclaredConstructor(outputClassFieldClasses);
38+
outputClassConstructor.setAccessible(true);
39+
} catch (NoSuchMethodException exception) {
40+
throw new TransformerRuntimeException(
41+
String.format(
42+
"Query transformation: Could not find a valid constructor in target class %s",
43+
outputClass.getName()
44+
),
45+
exception
46+
);
47+
}
48+
}
49+
50+
@SuppressWarnings("unchecked")
51+
public T transform(final Object[] objects) {
52+
try {
53+
54+
// If we have a single entry in the result, and that entry matches the desired result class
55+
// we can just give it back, no casting, nor type-checking needed. We can just unbox it and
56+
// give it back as-is!
57+
// Example: "SELECT p FROM Person p"
58+
if (objects.length == 1 && outputClass == objects[0].getClass()) {
59+
return (T) objects[0];
60+
}
61+
62+
if (objects.length != outputClassFieldClasses.length) {
63+
throw new TransformerRuntimeException(
64+
String.format(
65+
"Invalid query transforming: object count does not match target class field count for %s",
66+
outputClass.getName()
67+
)
68+
);
69+
}
70+
71+
// Unboxing not possible, we have a complex combined result, check single record entries for type-safety!
72+
for (var i = 0; i < objects.length; i++) {
73+
74+
// Everything ok, null matches every object and equal classes do not need casting!
75+
// Unboxing of primitives is automatic when we call the constructor of the target result class.
76+
if (objects[i] == null || outputClassFieldClasses[i].isPrimitive() || objects[i].getClass() == outputClassFieldClasses[i]) {
77+
continue;
78+
}
79+
80+
// Check if the two classes are either the same, or if it is a superclass or superinterface of it...
81+
// For example, casting an ArrayList to List can be done directly
82+
if (outputClassFieldClasses[i].isAssignableFrom(objects[i].getClass())) {
83+
objects[i] = outputClassFieldClasses[i].cast(objects[i]);
84+
continue;
85+
}
86+
87+
// Converter: STRING to ENUM
88+
// A string from the DB, that does not match a corresponding field inside the result class
89+
// should probably be an enum value, which implements the "valueOf" interface.
90+
if (objects[i] instanceof String && outputClassFieldClasses[i].isEnum()) {
91+
objects[i] = outputClassFieldClasses[i].getMethod("valueOf", String.class).invoke(
92+
null,
93+
objects[i].toString()
94+
);
95+
continue;
96+
}
97+
98+
// Converter: to WrappedValue
99+
if (CustomType.class.isAssignableFrom(outputClassFieldClasses[i])) {
100+
objects[i] = toWrappedValue(objects[i], outputClassFieldClasses[i]);
101+
continue;
102+
}
103+
104+
// Converter: Instant to OffsetDateTime
105+
if (objects[i] instanceof Instant && outputClassFieldClasses[i].isAssignableFrom(OffsetDateTime.class)) {
106+
objects[i] = OffsetDateTime.ofInstant(
107+
(Instant) objects[i],
108+
ZoneId.systemDefault()
109+
);
110+
continue;
111+
}
112+
113+
// Non-matching classes in fields. No converter found...
114+
throw new UnsupportedOperationException(
115+
String.format(
116+
"Query transformation: Type mismatch without converter. Cannot cast from %s to %s.",
117+
objects[i].getClass().getName(),
118+
outputClassFieldClasses[i].getName()
119+
)
120+
);
121+
}
122+
123+
return outputClassConstructor.newInstance(objects);
124+
125+
} catch (InstantiationException | IllegalAccessException | NoSuchMethodException | InvocationTargetException
126+
| UnsupportedOperationException exception) {
127+
throw new TransformerRuntimeException(
128+
String.format(
129+
"Query transformation: Given database record cannot be converted into target class %s",
130+
outputClass.getName()
131+
),
132+
exception
133+
);
134+
}
135+
}
136+
137+
@SuppressWarnings("unchecked")
138+
private <X extends CustomType<?>> X toWrappedValue(
139+
Object actualValue,
140+
Class<X> targetType
141+
) throws InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException {
142+
// types with a preferred constructor
143+
if (targetType.isAssignableFrom(ScaledBigDecimal.class)) {
144+
var constructor = targetType.getDeclaredConstructor(Double.class);
145+
var value = (Double) actualValue;
146+
return constructor.newInstance(value);
147+
}
148+
149+
// types using first suitable constructor
150+
Constructor<?>[] constructors = targetType.getDeclaredConstructors();
151+
152+
for (Constructor<?> constructor : constructors) {
153+
Class<?>[] parameterTypes = constructor.getParameterTypes();
154+
if (parameterTypes.length == 1) {
155+
if (Number.class.isAssignableFrom(parameterTypes[0])) {
156+
if (Long.class.equals(parameterTypes[0])) {
157+
var val = (Long) actualValue;
158+
return (X) constructor.newInstance(val);
159+
} else if (Integer.class.equals(parameterTypes[0])) {
160+
var val = (Integer) actualValue;
161+
return (X) constructor.newInstance(val);
162+
} else if (Double.class.equals(parameterTypes[0])) {
163+
var val = (Double) actualValue;
164+
return (X) constructor.newInstance(val);
165+
} else if (Float.class.equals(parameterTypes[0])) {
166+
var val = (Float) actualValue;
167+
return (X) constructor.newInstance(val);
168+
} else if (BigInteger.class.equals(parameterTypes[0])) {
169+
var val = BigInteger.valueOf((Long) actualValue);
170+
return (X) constructor.newInstance(val);
171+
} else if (BigDecimal.class.equals(parameterTypes[0])) {
172+
var val = BigDecimal.valueOf((Double) actualValue);
173+
return (X) constructor.newInstance(val);
174+
} else {
175+
throw new IllegalArgumentException("Unsupported number type");
176+
}
177+
} else if (String.class.equals(parameterTypes[0])) {
178+
var val = (String) actualValue;
179+
return (X) constructor.newInstance(val);
180+
} else if (Boolean.class.equals(parameterTypes[0])) {
181+
var val = (Boolean) actualValue;
182+
return (X) constructor.newInstance(val);
183+
} // Add more types as needed
184+
}
185+
}
186+
throw new IllegalArgumentException(targetType.getSimpleName() + " does not have a suitable single-value constructor");
187+
}
188+
}

0 commit comments

Comments
 (0)