Skip to content

Commit

Permalink
Delegate projections with a select clause to ORM
Browse files Browse the repository at this point in the history
Problem is that there's a bug in ORM that disallows projections of
single columns, but that will be resolved by hibernate/hibernate-orm#7874

Fixes quarkusio#31117
  • Loading branch information
FroMage committed Feb 21, 2024
1 parent 7d93733 commit 7166f52
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 95 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ public void close() {
private Map<String, Object> hints;

private Map<String, Map<String, Object>> filters;
private Class<?> projectionType;

public CommonPanacheQueryImpl(EntityManager em, String query, String originalQuery, String orderBy,
Object paramsArrayOrMap) {
Expand All @@ -69,7 +70,8 @@ public CommonPanacheQueryImpl(EntityManager em, String query, String originalQue
this.paramsArrayOrMap = paramsArrayOrMap;
}

private CommonPanacheQueryImpl(CommonPanacheQueryImpl<?> previousQuery, String newQueryString, String countQuery) {
private CommonPanacheQueryImpl(CommonPanacheQueryImpl<?> previousQuery, String newQueryString, String countQuery,
Class<?> projectionType) {
this.em = previousQuery.em;
this.query = newQueryString;
this.countQuery = countQuery;
Expand All @@ -81,6 +83,7 @@ private CommonPanacheQueryImpl(CommonPanacheQueryImpl<?> previousQuery, String n
this.lockModeType = previousQuery.lockModeType;
this.hints = previousQuery.hints;
this.filters = previousQuery.filters;
this.projectionType = projectionType;
}

// Builder
Expand All @@ -92,39 +95,24 @@ public <T> CommonPanacheQueryImpl<T> project(Class<T> type) {
selectQuery = q.getQueryString();
}

String lowerCasedTrimmedQuery = selectQuery.trim().replace('\n', ' ').replace('\r', ' ').toLowerCase();
String lowerCasedTrimmedQuery = PanacheJpaUtil.trimForAnalysis(selectQuery);
if (lowerCasedTrimmedQuery.startsWith("select new ")
|| lowerCasedTrimmedQuery.startsWith("select distinct new ")) {
throw new PanacheQueryException("Unable to perform a projection on a 'select [distinct]? new' query: " + query);
}

// If the query starts with a select clause, we generate an HQL query
// using the fields in the select clause:
// Initial query: select e.field1, e.field2 from EntityClass e
// New query: SELECT new org.acme.ProjectionClass(e.field1, e.field2) from EntityClass e
// If the query starts with a select clause, we pass it on to ORM which can handle that via a projection type
if (lowerCasedTrimmedQuery.startsWith("select ")) {
int endSelect = lowerCasedTrimmedQuery.indexOf(" from ");
String trimmedQuery = selectQuery.trim().replace('\n', ' ').replace('\r', ' ');
// 7 is the length of "select "
String selectClause = trimmedQuery.substring(7, endSelect).trim();
String from = trimmedQuery.substring(endSelect);
StringBuilder newQuery = new StringBuilder("select ");
// Handle select-distinct. HQL example: select distinct new org.acme.ProjectionClass...
boolean distinctQuery = selectClause.toLowerCase().startsWith("distinct ");
if (distinctQuery) {
// 9 is the length of "distinct "
selectClause = selectClause.substring(9).trim();
newQuery.append("distinct ");
}

newQuery.append("new ").append(type.getName()).append("(").append(selectClause).append(")").append(from);
return new CommonPanacheQueryImpl<>(this, newQuery.toString(), "select count(*) " + from);
// just pass it through
return new CommonPanacheQueryImpl<>(this, query, countQuery, type);
}

// FIXME: this assumes the query starts with "FROM " probably?

// build select clause with a constructor expression
String selectClause = "SELECT " + getParametersFromClass(type, null);
return new CommonPanacheQueryImpl<>(this, selectClause + selectQuery,
"select count(*) " + selectQuery);
"select count(*) " + selectQuery, null);
}

private StringBuilder getParametersFromClass(Class<?> type, String parentParameter) {
Expand Down Expand Up @@ -392,10 +380,10 @@ private Query createBaseQuery() {
Query jpaQuery;
if (PanacheJpaUtil.isNamedQuery(query)) {
String namedQuery = query.substring(1);
jpaQuery = em.createNamedQuery(namedQuery);
jpaQuery = em.createNamedQuery(namedQuery, projectionType);
} else {
try {
jpaQuery = em.createQuery(orderBy != null ? query + orderBy : query);
jpaQuery = em.createQuery(orderBy != null ? query + orderBy : query, projectionType);
} catch (IllegalArgumentException x) {
throw NamedQueryUtil.checkForNamedQueryMistake(x, originalQuery);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public class CommonPanacheQueryImpl<Entity> {
private Map<String, Object> hints;

private Map<String, Map<String, Object>> filters;
private Class<?> projectionType;

public CommonPanacheQueryImpl(Uni<Mutiny.Session> em, String query, String originalQuery, String orderBy,
Object paramsArrayOrMap) {
Expand All @@ -61,7 +62,8 @@ public CommonPanacheQueryImpl(Uni<Mutiny.Session> em, String query, String origi
this.paramsArrayOrMap = paramsArrayOrMap;
}

private CommonPanacheQueryImpl(CommonPanacheQueryImpl<?> previousQuery, String newQueryString, String countQuery) {
private CommonPanacheQueryImpl(CommonPanacheQueryImpl<?> previousQuery, String newQueryString, String countQuery,
Class<?> projectionType) {
this.em = previousQuery.em;
this.query = newQueryString;
this.countQuery = countQuery;
Expand All @@ -73,6 +75,7 @@ private CommonPanacheQueryImpl(CommonPanacheQueryImpl<?> previousQuery, String n
this.lockModeType = previousQuery.lockModeType;
this.hints = previousQuery.hints;
this.filters = previousQuery.filters;
this.projectionType = projectionType;
}

// Builder
Expand All @@ -83,38 +86,24 @@ public <T> CommonPanacheQueryImpl<T> project(Class<T> type) {
selectQuery = NamedQueryUtil.getNamedQuery(query.substring(1));
}

String lowerCasedTrimmedQuery = selectQuery.trim().toLowerCase();
if (lowerCasedTrimmedQuery.startsWith("select new ")) {
throw new PanacheQueryException("Unable to perform a projection on a 'select new' query: " + query);
String lowerCasedTrimmedQuery = PanacheJpaUtil.trimForAnalysis(selectQuery);
if (lowerCasedTrimmedQuery.startsWith("select new ")
|| lowerCasedTrimmedQuery.startsWith("select distinct new ")) {
throw new PanacheQueryException("Unable to perform a projection on a 'select [distinct]? new' query: " + query);
}

// If the query starts with a select clause, we generate an HQL query
// using the fields in the select clause:
// Initial query: select e.field1, e.field2 from EntityClass e
// New query: SELECT new org.acme.ProjectionClass(e.field1, e.field2) from EntityClass e
// If the query starts with a select clause, we pass it on to ORM which can handle that via a projection type
if (lowerCasedTrimmedQuery.startsWith("select ")) {
int endSelect = lowerCasedTrimmedQuery.indexOf(" from ");
String trimmedQuery = selectQuery.trim();
// 7 is the length of "select "
String selectClause = trimmedQuery.substring(7, endSelect).trim();
String from = trimmedQuery.substring(endSelect);
StringBuilder newQuery = new StringBuilder("select ");
// Handle select-distinct. HQL example: select distinct new org.acme.ProjectionClass...
boolean distinctQuery = selectClause.toLowerCase().startsWith("distinct ");
if (distinctQuery) {
// 9 is the length of "distinct "
selectClause = selectClause.substring(9).trim();
newQuery.append("distinct ");
}

newQuery.append("new ").append(type.getName()).append("(").append(selectClause).append(")").append(from);
return new CommonPanacheQueryImpl<>(this, newQuery.toString(), "select count(*) " + from);
// just pass it through
return new CommonPanacheQueryImpl<>(this, query, countQuery, type);
}

// FIXME: this assumes the query starts with "FROM " probably?

// build select clause with a constructor expression
String selectClause = "SELECT " + getParametersFromClass(type, null);
return new CommonPanacheQueryImpl<>(this, selectClause + selectQuery,
"select count(*) " + selectQuery);
"select count(*) " + selectQuery, type);
}

private StringBuilder getParametersFromClass(Class<?> type, String parentParameter) {
Expand Down Expand Up @@ -305,7 +294,7 @@ private String countQuery(String selectQuery) {
@SuppressWarnings({ "unchecked", "rawtypes" })
public <T extends Entity> Uni<List<T>> list() {
return em.flatMap(session -> {
Mutiny.Query<?> jpaQuery = createQuery(session);
Mutiny.SelectionQuery<?> jpaQuery = createQuery(session);
return (Uni) applyFilters(session, () -> jpaQuery.getResultList());
});
}
Expand All @@ -323,23 +312,23 @@ public <T extends Entity> Multi<T> stream() {
@SuppressWarnings("unchecked")
public <T extends Entity> Uni<T> firstResult() {
return em.flatMap(session -> {
Mutiny.Query<?> jpaQuery = createQuery(session, 1);
Mutiny.SelectionQuery<?> jpaQuery = createQuery(session, 1);
return applyFilters(session, () -> jpaQuery.getResultList().map(list -> list.isEmpty() ? null : (T) list.get(0)));
});
}

@SuppressWarnings("unchecked")
public <T extends Entity> Uni<T> singleResult() {
return em.flatMap(session -> {
Mutiny.Query<?> jpaQuery = createQuery(session);
Mutiny.SelectionQuery<?> jpaQuery = createQuery(session);
return applyFilters(session, () -> jpaQuery.getSingleResult().map(v -> (T) v))
// FIXME: workaround https://github.com/hibernate/hibernate-reactive/issues/263
.onFailure(CompletionException.class).transform(t -> t.getCause());
});
}

private Mutiny.Query<?> createQuery(Mutiny.Session em) {
Mutiny.Query<?> jpaQuery = createBaseQuery(em);
private Mutiny.SelectionQuery<?> createQuery(Mutiny.Session em) {
Mutiny.SelectionQuery<?> jpaQuery = createBaseQuery(em);

if (range != null) {
jpaQuery.setFirstResult(range.getStartIndex());
Expand All @@ -364,8 +353,8 @@ private Mutiny.Query<?> createQuery(Mutiny.Session em) {
return jpaQuery;
}

private Mutiny.Query<?> createQuery(Mutiny.Session em, int maxResults) {
Mutiny.Query<?> jpaQuery = createBaseQuery(em);
private Mutiny.SelectionQuery<?> createQuery(Mutiny.Session em, int maxResults) {
Mutiny.SelectionQuery<?> jpaQuery = createBaseQuery(em);

if (range != null) {
jpaQuery.setFirstResult(range.getStartIndex());
Expand All @@ -385,14 +374,14 @@ private Mutiny.Query<?> createQuery(Mutiny.Session em, int maxResults) {
}

@SuppressWarnings("unchecked")
private Mutiny.Query<?> createBaseQuery(Mutiny.Session em) {
Mutiny.Query<?> jpaQuery;
private Mutiny.SelectionQuery<?> createBaseQuery(Mutiny.Session em) {
Mutiny.SelectionQuery<?> jpaQuery;
if (PanacheJpaUtil.isNamedQuery(query)) {
String namedQuery = query.substring(1);
jpaQuery = em.createNamedQuery(namedQuery);
jpaQuery = em.createNamedQuery(namedQuery, projectionType);
} else {
try {
jpaQuery = em.createQuery(orderBy != null ? query + orderBy : query);
jpaQuery = em.createQuery(orderBy != null ? query + orderBy : query, projectionType);
} catch (IllegalArgumentException x) {
throw NamedQueryUtil.checkForNamedQueryMistake(x, originalQuery);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.quarkus.panache.hibernate.common.runtime;

import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand Down Expand Up @@ -46,8 +47,13 @@ public static String getFastCountQuery(String query) {
Matcher selectMatcher = SELECT_PATTERN.matcher(query);
if (selectMatcher.matches()) {
// this one cannot be null
String firstSelection = selectMatcher.group(1).trim();
if (firstSelection.toLowerCase().startsWith("distinct ")) {
String firstSelection = selectMatcher.group(1).trim().toLowerCase(Locale.ROOT);
if (firstSelection.startsWith("distinct")) {
// if firstSelection matched distinct only, we have something wrong in our selection list, probably functions/parens
// so bail out
if (firstSelection.length() == 8) {
return getCountQueryUsingParser(query);
}
// this one can be null
String secondSelection = selectMatcher.group(2);
// we can only count distinct single columns
Expand Down Expand Up @@ -96,24 +102,41 @@ public static String getEntityName(Class<?> entityClass) {
return entityClass.getName();
}

/**
* Removes \n, \r and outside spaces, and turns to lower case. DO NOT USE the result to pass it on to ORM,
* because the query is likely to be invalid since we replace newlines even if they
* are in quoted strings. This is only useful to analyse the start of the query for
* quick processing. NEVER use this to pass it to the DB or to replace user queries.
*/
public static String trimForAnalysis(String query) {
// first replace single chars \n\r\t to spaces
// turn to lower case
String ret = query.replace('\n', ' ').replace('\r', ' ').replace('\t', ' ').toLowerCase(Locale.ROOT);
// if we have more than one space, replace with one
if (ret.indexOf(" ") != -1) {
ret = ret.replaceAll("\\s+", " ");
}
// replace outer spaces
return ret.trim();
}

public static String createFindQuery(Class<?> entityClass, String query, int paramCount) {
if (query == null) {
return "FROM " + getEntityName(entityClass);
}

String trimmed = query.replace('\n', ' ').replace('\r', ' ').trim();
if (trimmed.isEmpty()) {
String trimmedForAnalysis = trimForAnalysis(query);
if (trimmedForAnalysis.isEmpty()) {
return "FROM " + getEntityName(entityClass);
}

String trimmedLc = trimmed.toLowerCase();
if (trimmedLc.startsWith("from ") || trimmedLc.startsWith("select ")) {
if (trimmedForAnalysis.startsWith("from ") || trimmedForAnalysis.startsWith("select ")) {
return query;
}
if (trimmedLc.startsWith("order by ")) {
if (trimmedForAnalysis.startsWith("order by ")) {
return "FROM " + getEntityName(entityClass) + " " + query;
}
if (trimmedLc.indexOf(' ') == -1 && trimmedLc.indexOf('=') == -1 && paramCount == 1) {
if (trimmedForAnalysis.indexOf(' ') == -1 && trimmedForAnalysis.indexOf('=') == -1 && paramCount == 1) {
query += " = ?1";
}
return "FROM " + getEntityName(entityClass) + " WHERE " + query;
Expand All @@ -130,19 +153,18 @@ public static String createCountQuery(Class<?> entityClass, String query, int pa
if (query == null)
return "SELECT COUNT(*) FROM " + getEntityName(entityClass);

String trimmed = query.trim();
if (trimmed.isEmpty())
String trimmedForAnalysis = trimForAnalysis(query);
if (trimmedForAnalysis.isEmpty())
return "SELECT COUNT(*) FROM " + getEntityName(entityClass);

String trimmedLc = trimmed.toLowerCase();
if (trimmedLc.startsWith("from ")) {
if (trimmedForAnalysis.startsWith("from ")) {
return "SELECT COUNT(*) " + query;
}
if (trimmedLc.startsWith("order by ")) {
if (trimmedForAnalysis.startsWith("order by ")) {
// ignore it
return "SELECT COUNT(*) FROM " + getEntityName(entityClass);
}
if (trimmedLc.indexOf(' ') == -1 && trimmedLc.indexOf('=') == -1 && paramCount == 1) {
if (trimmedForAnalysis.indexOf(' ') == -1 && trimmedForAnalysis.indexOf('=') == -1 && paramCount == 1) {
query += " = ?1";
}
return "SELECT COUNT(*) FROM " + getEntityName(entityClass) + " WHERE " + query;
Expand All @@ -153,26 +175,29 @@ public static String createUpdateQuery(Class<?> entityClass, String query, int p
throw new PanacheQueryException("Query string cannot be null");
}

String trimmed = query.trim();
if (trimmed.isEmpty()) {
String trimmedForAnalysis = trimForAnalysis(query);
if (trimmedForAnalysis.isEmpty()) {
throw new PanacheQueryException("Query string cannot be empty");
}

String trimmedLc = trimmed.toLowerCase();
// backwards compat trying to be helpful, remove the from
if (trimmedLc.startsWith("update from")) {
return "update " + trimmed.substring(11);
if (trimmedForAnalysis.startsWith("update from")) {
// find the original from and skip it
int index = query.toLowerCase(Locale.ROOT).indexOf("from");
return "update " + query.substring(index + 4);
}
if (trimmedLc.startsWith("update ")) {
if (trimmedForAnalysis.startsWith("update ")) {
return query;
}
if (trimmedLc.startsWith("from ")) {
return "UPDATE " + trimmed.substring(5);
if (trimmedForAnalysis.startsWith("from ")) {
// find the original from and skip it
int index = query.toLowerCase(Locale.ROOT).indexOf("from");
return "UPDATE " + query.substring(index + 4);
}
if (trimmedLc.indexOf(' ') == -1 && trimmedLc.indexOf('=') == -1 && paramCount == 1) {
if (trimmedForAnalysis.indexOf(' ') == -1 && trimmedForAnalysis.indexOf('=') == -1 && paramCount == 1) {
query += " = ?1";
}
if (trimmedLc.startsWith("set ")) {
if (trimmedForAnalysis.startsWith("set ")) {
return "UPDATE " + getEntityName(entityClass) + " " + query;
}
return "UPDATE " + getEntityName(entityClass) + " SET " + query;
Expand All @@ -182,22 +207,21 @@ public static String createDeleteQuery(Class<?> entityClass, String query, int p
if (query == null)
return "DELETE FROM " + getEntityName(entityClass);

String trimmed = query.trim();
if (trimmed.isEmpty())
String trimmedForAnalysis = trimForAnalysis(query);
if (trimmedForAnalysis.isEmpty())
return "DELETE FROM " + getEntityName(entityClass);

String trimmedLc = trimmed.toLowerCase();
if (trimmedLc.startsWith("delete ")) {
if (trimmedForAnalysis.startsWith("delete ")) {
return query;
}
if (trimmedLc.startsWith("from ")) {
if (trimmedForAnalysis.startsWith("from ")) {
return "DELETE " + query;
}
if (trimmedLc.startsWith("order by ")) {
if (trimmedForAnalysis.startsWith("order by ")) {
// ignore it
return "DELETE FROM " + getEntityName(entityClass);
}
if (trimmedLc.indexOf(' ') == -1 && trimmedLc.indexOf('=') == -1 && paramCount == 1) {
if (trimmedForAnalysis.indexOf(' ') == -1 && trimmedForAnalysis.indexOf('=') == -1 && paramCount == 1) {
query += " = ?1";
}
return "DELETE FROM " + getEntityName(entityClass) + " WHERE " + query;
Expand Down
Loading

0 comments on commit 7166f52

Please sign in to comment.