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
Original file line number Diff line number Diff line change
Expand Up @@ -274,30 +274,41 @@ private Class<?> resolveProjectionType(Query.PropertyProjection projection) {
private void assignOrderBy(CriteriaQuery<?> cq, JpaQueryContext context) {
if (isCountOnly()) {
// a count returns one row whatever the order, and an ORDER BY on a column that is not aggregated fails
// on H2 and most other databases, so a count query leaves the criteria's orders out
// on H2 and most other databases, so a count query leaves the criteria's orders out, handled ones too
return;
}
List<Query.Order> orders = detachedCriteria.getOrders();
if (!orders.isEmpty()) {
var jpaOrders = orders.stream()
.map(order -> {
var propertyName = order.getProperty();
SqlProjection sqlProjection = findSqlProjection(propertyName, false);
Expression<?> expression = sqlProjection != null ?
context.getSelectionAlias(sqlProjection.getColumnAlias()) :
context.getFullyQualifiedExpression(propertyName);
if (order.isIgnoreCase() && expression.getJavaType().equals(String.class)) {
return order.getDirection().equals(Query.Order.Direction.ASC) ?
criteriaBuilder.asc(criteriaBuilder.lower((Expression<String>) expression)) :
criteriaBuilder.desc(criteriaBuilder.lower((Expression<String>) expression));
} else {
return order.getDirection().equals(Query.Order.Direction.ASC) ?
criteriaBuilder.asc(expression) :
criteriaBuilder.desc(expression);
}
})
.toArray(jakarta.persistence.criteria.Order[]::new);
cq.orderBy(jpaOrders);
List<jakarta.persistence.criteria.Order> jpaOrders = new ArrayList<>();
for (Query.Order order : orders) {
PredicateGenerator.OrderHandler customHandler = PredicateGenerator.orderHandler(order.getClass());
if (customHandler != null) {
// the handler of a Query.Order subclass builds its JPA order, or leaves the order out
jakarta.persistence.criteria.Order customOrder =
customHandler.handle(cq, context.getRoot(), criteriaBuilder, order, context);
if (customOrder != null) {
jpaOrders.add(customOrder);
}
continue;
}
String propertyName = order.getProperty();
SqlProjection sqlProjection = findSqlProjection(propertyName, false);
Expression<?> expression = sqlProjection != null ?
context.getSelectionAlias(sqlProjection.getColumnAlias()) :
context.getFullyQualifiedExpression(propertyName);
jakarta.persistence.criteria.Order jpaOrder;
if (order.isIgnoreCase() && expression.getJavaType().equals(String.class)) {
jpaOrder = order.getDirection().equals(Query.Order.Direction.ASC) ?
criteriaBuilder.asc(criteriaBuilder.lower((Expression<String>) expression)) :
criteriaBuilder.desc(criteriaBuilder.lower((Expression<String>) expression));
} else {
jpaOrder = order.getDirection().equals(Query.Order.Direction.ASC) ?
criteriaBuilder.asc(expression) :
criteriaBuilder.desc(expression);
}
jpaOrders.add(jpaOrder);
}
cq.orderBy(jpaOrders.toArray(new jakarta.persistence.criteria.Order[0]));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
*
* @since 8.0
*/
public class JpaQueryContext implements Cloneable {
public class JpaQueryContext implements Cloneable, QueryPathContext {

private final AliasRegistry aliasRegistry;
private final JoinTracker joinTracker;
Expand Down Expand Up @@ -110,6 +110,7 @@ public void setParent(JpaQueryContext parent) {
this.parent = parent;
}

@Override
public From<?, ?> getRoot() {
return joinTracker.getRoot();
}
Expand Down Expand Up @@ -143,11 +144,13 @@ public void registerSelectionAlias(String alias, Expression<?> selection) {
}

/**
* Returns the selection recorded under an alias by {@link #registerSelectionAlias(String, Expression)}.
* Returns the selection recorded under an alias by {@link #registerSelectionAlias(String, Expression)}. Unlike
* {@link #getFullyQualifiedExpression(String)}, which resolves a property first, this asks for the alias alone.
*
* @param alias the alias of the selection
* @return the selection, or {@code null} if no selection of this query has the alias
*/
@Override
public Expression<?> getSelectionAlias(String alias) {
return selectionAliases.get(alias);
}
Expand All @@ -173,6 +176,7 @@ public Expression<?> getAliasedExpression(String alias) {
return expr;
}

@Override
public Expression<?> getFullyQualifiedExpression(String path) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getFullyQualifiedExpression('total') still asks only the property resolver. The sqlProjection alias is recorded with registerSelectionAlias, so this call returns null, or an entity property named total. The context javadoc says this method accepts the alias of a projection.

Return the recorded selection when the path is that alias. Please order through getFullyQualifiedExpression('total'), not only getSelectionAlias.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probed this at 1e47f7e to see what each lookup returns for a sqlProjection alias, with width * 10 as scaled where no scaled property exists and width * 10 as height where height is a property:

alias getFullyQualifiedExpression getSelectionAlias built-in order(alias) built-in eq(alias, …)
scaled null; cb.desc(null) then fails with "Ambiguous method overloading for SqmCriteriaNodeBuilder#desc" the projection order by 1 desc "Cannot use comparison criteria on non-existent property [scaled]"
height the property, order by spb1_0.height desc the projection order by 1 desc where spb1_0.height=?

For sqlGroupProjection 'width, sum(height) as total', a handler using getSelectionAlias('total') gives order by 2 desc, the same SQL as order('total', 'desc').

The two built-in columns of that table are not an accident of the translator. They are the rule SQL, Hibernate 5 and this module already follow:

  • SQL scopes a select alias to ORDER BY, where it wins over a column of the same name, and never to WHERE. On H2 2.5.250, the test database: select width * 10 as height from t order by height desc returns 40, 20; ... where height = 9 restricts the column; ... where scaled = 20 fails with Column "SCALED" not found.
  • Hibernate 5's Criteria API, which the plugin in Hibernate 7: let a CriterionHandler resolve property paths and order by expressions #16563 is porting from, does the same. Order.toSqlString resolves through CriteriaQueryTranslator.getColumnsUsingProjection, which first looks for a projection alias and then for a property; SimpleExpression.toSqlString, behind eq and the other restrictions, resolves through findColumns, which looks for a property only (hibernate-core 5.6.11).
  • This module encoded it when sqlProjection landed: assignOrderBy checks the sqlProjection alias before the property, and registerSelectionAlias keeps the alias out of property resolution, with "Column aliases never shadow properties in restrictions" in cb1f659 (Hibernate 7: support sqlProjection and sqlGroupProjection in criteria queries #16548).

Folding the selection into getFullyQualifiedExpression changes that behavior, because the built-in predicates resolve through the same method and assignCriteria runs after the projections are registered. Selection first makes eq('height', 9) restrict the projection instead of the column on every query whose projection alias matches a property, which is the shadowing the commit above rules out and SQL does not do. Selection as a fallback keeps eq as it is, but then a custom order resolves height to the property where order('height') resolves it to the projection, so the built-in order and the handler order the same alias differently.

Two lookups avoid both. An order handler that checks getSelectionAlias first and getFullyQualifiedExpression second orders exactly as order(alias) and as Hibernate 5's Order do, and a criterion handler that uses getFullyQualifiedExpression alone restricts exactly as eq does. So I would keep the accessor this commit adds rather than change the resolver.

What is wrong at this commit is the contract. The QueryPathContext.getFullyQualifiedExpression javadoc says it takes "the alias of a projection" and returns "the expression an alias or projection stands for". That holds for an alias the translator registers with registerAlias and not for the column alias of a sqlProjection, which is where this thread started. Could the javadoc say which aliases it resolves and point to getSelectionAlias for the column alias of a sqlProjection or sqlGroupProjection, and could the guide's order-handler paragraph say to check getSelectionAlias first, the way order(alias) does?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same change as the thread above (54a4454): the path resolves to the recorded selection when no property has the name, and the order goes through getFullyQualifiedExpression('total') in a spec of its own.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted in 3bdd024, on jdaugherty's analysis in his reply to your second comment: getFullyQualifiedExpression resolves as the built-in predicates do, so a projection alias is not folded into it. With width * 10 as height, resolving the alias there would make eq('height', 9) restrict the projection instead of the column, which SQL does not do (a select alias is visible to order by, not to where) and neither does Hibernate 5. Your first request, to expose a selection lookup and leave predicate resolution alone, is what getSelectionAlias is. An order handler asks it first and getFullyQualifiedExpression second, and then orders exactly as order(alias) does, for scaled (no such property) and for height (a property). The javadoc and the guide say so, and HibernateCriteriaBuilderSqlProjectionSpec pins it, including that eq('height', 9) restricts the property.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3bdd024: the resolver is back to what it was, and QueryPathContext.getFullyQualifiedExpression documents which aliases it resolves and points to getSelectionAlias for the column alias of a sqlProjection or sqlGroupProjection. The guide's order-handler paragraph says to ask getSelectionAlias first and getFullyQualifiedExpression second, and that a criterion handler asks the second alone. The new feature in HibernateCriteriaBuilderSqlProjectionSpec runs your two cases (scaled, which no property has, and height, which one has) and the eq('height', 9) that must restrict the column.

if (parent != null) {
if ("{alias}".equals(path)) {
Expand All @@ -185,6 +189,7 @@ public Expression<?> getFullyQualifiedExpression(String path) {
return resolver.resolve(path);
}

@Override
public Path<?> getFullyQualifiedPath(String path) {
if (parent != null) {
if ("{alias}".equals(path)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
import java.util.Objects;
import java.util.ServiceLoader;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;

import groovy.lang.Closure;

import jakarta.persistence.criteria.AbstractQuery;
import jakarta.persistence.criteria.CriteriaBuilder;
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Expression;
import jakarta.persistence.criteria.From;
import jakarta.persistence.criteria.JoinType;
Expand Down Expand Up @@ -90,6 +92,123 @@ Predicate handle(
HibernateCriteriaBuilder criteriaBuilder,
Query.Criterion criterion
);

/**
* Converts a criterion with the context of the query it belongs to. The context resolves a property path
* the way the built-in criteria do: {@link QueryPathContext#getFullyQualifiedPath(String)} turns
* {@code alias.property}, for an alias created with {@code createAlias}, or {@code association.property}
* into a path on the join GORM created for that association, so a custom criterion and a GORM criterion on
* the same association restrict the same row instead of each joining the association again. This default
* ignores the context and calls {@link #handle(AbstractQuery, From, HibernateCriteriaBuilder, Query.Criterion)};
* a handler that needs the context implements {@link ContextualCriterionHandler}.
*
* @param criteriaQuery the query or subquery the predicate is for
* @param root the root of that query
* @param criteriaBuilder the criteria builder
* @param criterion the criterion to convert
* @param context the context of the query: its root, its joins and its aliases
* @return the predicate, or {@code null} to add none
* @since 8.1
*/
default Predicate handle(
AbstractQuery<?> criteriaQuery,
From<?, ?> root,
HibernateCriteriaBuilder criteriaBuilder,
Query.Criterion criterion,
QueryPathContext context
) {
return handle(criteriaQuery, root, criteriaBuilder, criterion);
}
}

/**
* A {@link CriterionHandler} that is given the context of the query, through which it resolves property paths
* with {@link QueryPathContext#getFullyQualifiedPath(String)}. A closure with five parameters registered with
* {@link #registerCriterionHandler(Class, Closure)} becomes one, with no {@code as}:
* <pre>
* PredicateGenerator.registerCriterionHandler(MyCriterion) { query, root, cb, criterion, context -&gt;
* def c = criterion as MyCriterion
* cb.like(context.getFullyQualifiedPath(c.property) as Expression&lt;String&gt;, c.value as String)
* }
* </pre>
*
* @since 8.1
*/
@FunctionalInterface
public interface ContextualCriterionHandler extends CriterionHandler {

@Override
Predicate handle(
AbstractQuery<?> criteriaQuery,
From<?, ?> root,
HibernateCriteriaBuilder criteriaBuilder,
Query.Criterion criterion,
QueryPathContext context
);

/**
* Called by code that has no context to give; the handler then receives {@code null} for it.
*/
@Override
default Predicate handle(
AbstractQuery<?> criteriaQuery,
From<?, ?> root,
HibernateCriteriaBuilder criteriaBuilder,
Query.Criterion criterion
) {
return handle(criteriaQuery, root, criteriaBuilder, criterion, null);
}
}

/**
* Extension point for ordering a criteria query by something other than a property. Register a handler for a
* {@link Query.Order} subclass to have it converted to a JPA {@link jakarta.persistence.criteria.Order}, in
* the place the order has among the query's orders. The order reaches the query through
* {@code order(Query.Order)} of the criteria builder or of a detached criteria.
*
* <pre>
* class ByRandom extends Query.Order {
* ByRandom() { super('random') }
* }
* PredicateGenerator.registerOrderHandler(ByRandom) { query, root, cb, order, context -&gt;
* cb.asc(cb.function('random', Double))
* }
* </pre>
*
* @since 8.1
*/
@FunctionalInterface
public interface OrderHandler {

/**
* @param criteriaQuery the query, whose result type tells a query for the entity from a projection
* @param root the root of the query
* @param criteriaBuilder the criteria builder
* @param order the order to convert
* @param context the context of the query: its root, its joins and its aliases
* @return the JPA order, or {@code null} to leave this order out of the query
*/
jakarta.persistence.criteria.Order handle(
CriteriaQuery<?> criteriaQuery,
From<?, ?> root,
HibernateCriteriaBuilder criteriaBuilder,
Query.Order order,
QueryPathContext context
);
}

/**
* SPI for contributing an {@link OrderHandler} via {@link ServiceLoader}. Register an implementation in
* {@code META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider}; it is
* discovered on first query execution, like a {@link CriterionHandlerProvider}.
*
* @since 8.1
*/
public interface OrderHandlerProvider {

Class<? extends Query.Order> orderType();

OrderHandler orderHandler();
}

/**
Expand Down Expand Up @@ -119,13 +238,69 @@ public interface CriterionHandlerProvider {
public static final Map<Class<? extends Query.Criterion>, CriterionHandler> CUSTOM_HANDLERS =
new ConcurrentHashMap<>();

private static final AtomicBoolean SERVICE_LOADERS_INITIALIZED = new AtomicBoolean(false);
private static final Map<Class<? extends Query.Order>, OrderHandler> CUSTOM_ORDER_HANDLERS =
new ConcurrentHashMap<>();

private static final Object SERVICE_LOADER_LOCK = new Object();

private static volatile boolean serviceLoadersInitialized = false;

/**
* Loads the handler providers once. A caller that arrives while another thread is loading them waits until the
* loading is complete, so no query decides on a handler before the discovered ones are registered. Discovery uses
* {@code putIfAbsent}, so a discovered handler does not replace one that is already registered; registering in
* code uses {@code put}, so a handler registered in code replaces a discovered one.
*/
private static void loadServiceProviders() {
if (SERVICE_LOADERS_INITIALIZED.compareAndSet(false, true)) {
ServiceLoader.load(CriterionHandlerProvider.class,
Thread.currentThread().getContextClassLoader())
if (serviceLoadersInitialized) {
return;
}
synchronized (SERVICE_LOADER_LOCK) {
if (serviceLoadersInitialized) {
return;
}
ClassLoader classLoader = Thread.currentThread().getContextClassLoader();
ServiceLoader.load(CriterionHandlerProvider.class, classLoader)
.forEach(p -> CUSTOM_HANDLERS.putIfAbsent(p.criterionType(), p.criterionHandler()));
ServiceLoader.load(OrderHandlerProvider.class, classLoader)
.forEach(p -> CUSTOM_ORDER_HANDLERS.putIfAbsent(p.orderType(), p.orderHandler()));
serviceLoadersInitialized = true;
}
}

/**
* Registers an {@link OrderHandler} for the given order type. The handler converts every order of exactly
* that type; programmatic registration takes precedence over a {@link ServiceLoader}-discovered handler.
*
* @param type the exact order class to handle
* @param handler the handler that converts the order to a JPA order
* @since 8.1
*/
public static void registerOrderHandler(Class<? extends Query.Order> type, OrderHandler handler) {
CUSTOM_ORDER_HANDLERS.put(type, handler);
}

/**
* Returns the handler registered for an order type, loading the {@link ServiceLoader} providers first.
*
* @param type the exact order class
* @return the handler, or {@code null} when the type is ordered by its property as usual
* @since 8.1
*/
public static OrderHandler orderHandler(Class<? extends Query.Order> type) {
loadServiceProviders();
return CUSTOM_ORDER_HANDLERS.get(type);
}

/**
* Removes all registered custom order handlers and resets the ServiceLoader flag. Useful for test cleanup.
*
* @since 8.1
*/
public static void clearCustomOrderHandlers() {
synchronized (SERVICE_LOADER_LOCK) {
CUSTOM_ORDER_HANDLERS.clear();
serviceLoadersInitialized = false;
}
}

Expand All @@ -141,13 +316,36 @@ public static void registerCriterionHandler(Class<? extends Query.Criterion> typ
CUSTOM_HANDLERS.put(type, handler);
}

/**
* Registers a closure as the {@link CriterionHandler} for the given criterion type. A closure with five
* parameters, {@code query, root, criteriaBuilder, criterion, context}, becomes a
* {@link ContextualCriterionHandler}; any other closure becomes a {@link CriterionHandler} and is called with
* four arguments. This is what makes the two shapes work without {@code as}, as {@link #registerOrderHandler} does
* for an order handler.
*
* @param type the exact criterion class to handle
* @param handler the closure that converts the criterion to a JPA {@link Predicate}
* @since 8.1
*/
public static void registerCriterionHandler(Class<? extends Query.Criterion> type, Closure<?> handler) {
if (handler.getMaximumNumberOfParameters() == 5) {
registerCriterionHandler(type, (ContextualCriterionHandler) (query, root, criteriaBuilder, criterion, context) ->
(Predicate) handler.call(query, root, criteriaBuilder, criterion, context));
} else {
registerCriterionHandler(type, (CriterionHandler) (query, root, criteriaBuilder, criterion) ->
(Predicate) handler.call(query, root, criteriaBuilder, criterion));
}
}

/**
* Removes all registered custom criterion handlers and resets the ServiceLoader flag.
* Useful for test cleanup.
*/
public static void clearCustomCriterionHandlers() {
CUSTOM_HANDLERS.clear();
SERVICE_LOADERS_INITIALIZED.set(false);
synchronized (SERVICE_LOADER_LOCK) {
CUSTOM_HANDLERS.clear();
serviceLoadersInitialized = false;
}
}

private final HibernateCriteriaBuilder criteriaBuilder;
Expand Down Expand Up @@ -190,7 +388,7 @@ public Predicate handleCriterion(
if (criterion instanceof Query.Criterion c) {
CriterionHandler customHandler = CUSTOM_HANDLERS.get(c.getClass());
if (customHandler != null) {
return customHandler.handle(criteriaQuery, root, criteriaBuilder, c);
return customHandler.handle(criteriaQuery, root, criteriaBuilder, c, fromsByProvider);
}
}

Expand Down
Loading