Skip to content

Commit 669ed8b

Browse files
authored
Merge pull request #10 from aboutbits/ab-254-extract-querytransformer-to-our-backend-lib
AB-254 Extract querytransformer to our backend lib
2 parents 0ab92b2 + cc4d535 commit 669ed8b

10 files changed

Lines changed: 1073 additions & 0 deletions

File tree

src/main/java/it/aboutbits/springboot/toolbox/.gitkeep

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

0 commit comments

Comments
 (0)