You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by bo...@apache.org on 2026/10/22 17:40:38 UTC

(grails-core) 02/02: Hibernate 7: give criterion handlers the query context and let a handler order by an expression

This is an automated email from the ASF dual-hosted git repository.

borinquenkid pushed a commit to branch feat/criteria-handler-context-8.1.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git

commit 66f1f26e20776b349f0addfe41b5a08c618ba39c
Author: Walter Duque de Estrada <wb...@mac.com>
AuthorDate: Thu Oct 22 11:56:02 2026 -0500

    Hibernate 7: give criterion handlers the query context and let a handler order by an expression
    
    Resolves #16563 on 8.x. A plugin or an application can extend the
    Hibernate 7 criteria builder with its own criteria and orders.
    
    - A criterion handler can take the query's QueryPathContext, a small
      interface that JpaQueryContext implements (getRoot, getFullyQualifiedPath,
      getFullyQualifiedExpression). Through it a handler resolves
      alias.property or association.property to the join GORM created for the
      association, so a custom criterion and a built-in one on the same
      association restrict the same row instead of each joining it again. The
      four-argument handler keeps working; ContextualCriterionHandler is the
      five-argument form, and a closure with five parameters is coerced to it.
    - An OrderHandler turns a Query.Order subclass into a JPA order by any
      expression, in its place among the query's orders, registered in code or
      through a META-INF/services OrderHandlerProvider.
    - Handler discovery waits for the providers: loadServiceProviders loads
      under a lock and publishes its flag only after both ServiceLoader loops,
      so a query that arrives during the discovery no longer decides, with the
      handlers still unregistered, that an order has no handler. A handler
      registered in code stays ahead of a discovered one.
    - A count query leaves its orders out, handled ones too (the fix of the
      previous commit), so no handler runs for a count.
    
    Covered by HibernateCriteriaBuilderDirectSpec (a contextual handler on an
    association path before and after the association block, an order handler
    through the builder and DetachedCriteria.list(), a count with a handled
    order), OrderHandlerDiscoverySpec (a lookup during a blocked discovery
    waits for the handler), PredicateGeneratorSpec and
    JpaCriteriaQueryCreatorSpec. Documented in the Hibernate 7 guide and the
    what's new; the criteria-extension example exercises both handler kinds
    through providers.
    
    This is the 8.1.x version of #16696, which becomes its merge-up onto the
    Java-to-Groovy Hibernate 7 branch.
---
 .../hibernate/query/JpaCriteriaQueryCreator.java   |  51 +++---
 .../orm/hibernate/query/JpaQueryContext.java       |   5 +-
 .../orm/hibernate/query/PredicateGenerator.java    | 189 ++++++++++++++++++++-
 .../orm/hibernate/query/QueryPathContext.java      |  49 ++++++
 .../JpaCriteriaQueryCreatorSpec.groovy             |  58 +++++++
 .../OrderHandlerDiscoverySpec.groovy               | 115 +++++++++++++
 .../hibernatequery/PredicateGeneratorSpec.groovy   |  42 +++++
 .../orm/HibernateCriteriaBuilderDirectSpec.groovy  | 102 +++++++++++
 ...e.query.PredicateGenerator$OrderHandlerProvider |  19 +++
 .../docs/src/docs/asciidoc/querying/criteria.adoc  |  88 ++++++++++
 grails-doc/src/en/guide/introduction/whatsNew.adoc |  10 ++
 .../services/example/ProductSearchService.groovy   |  11 ++
 .../groovy/example/CriteriaExtensionSpec.groovy    |  36 ++++
 .../HibernateCriteriaBuilderExtensions.groovy      |  16 ++
 .../NumberLikeCriterionHandlerProvider.groovy      |  10 +-
 .../groovy/example/query/PriceDistanceOrder.groovy |  40 +++++
 ...vy => PriceDistanceOrderHandlerProvider.groovy} |  29 ++--
 ...e.query.PredicateGenerator$OrderHandlerProvider |   1 +
 18 files changed, 825 insertions(+), 46 deletions(-)

diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaCriteriaQueryCreator.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaCriteriaQueryCreator.java
index 879c0016b3..c5c9cbbd08 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaCriteriaQueryCreator.java
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaCriteriaQueryCreator.java
@@ -274,30 +274,41 @@ public class JpaCriteriaQueryCreator<T> {
     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]));
         }
     }
 
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaQueryContext.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaQueryContext.java
index 81061df296..bf180c3c55 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaQueryContext.java
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/JpaQueryContext.java
@@ -34,7 +34,7 @@ import jakarta.persistence.criteria.Path;
  *
  * @since 8.0
  */
-public class JpaQueryContext implements Cloneable {
+public class JpaQueryContext implements Cloneable, QueryPathContext {
 
     private final AliasRegistry aliasRegistry;
     private final JoinTracker joinTracker;
@@ -110,6 +110,7 @@ public class JpaQueryContext implements Cloneable {
         this.parent = parent;
     }
 
+    @Override
     public From<?, ?> getRoot() {
         return joinTracker.getRoot();
     }
@@ -173,6 +174,7 @@ public class JpaQueryContext implements Cloneable {
         return expr;
     }
 
+    @Override
     public Expression<?> getFullyQualifiedExpression(String path) {
         if (parent != null) {
             if ("{alias}".equals(path)) {
@@ -185,6 +187,7 @@ public class JpaQueryContext implements Cloneable {
         return resolver.resolve(path);
     }
 
+    @Override
     public Path<?> getFullyQualifiedPath(String path) {
         if (parent != null) {
             if ("{alias}".equals(path)) {
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java
index 163c18a79a..717e3b35d1 100644
--- a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/PredicateGenerator.java
@@ -27,11 +27,11 @@ import java.util.Map;
 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 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;
@@ -90,6 +90,122 @@ public class PredicateGenerator {
             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 is coerced to it:
+     * <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)
+     *     } as PredicateGenerator.ContextualCriterionHandler)
+     * </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();
     }
 
     /**
@@ -119,13 +235,68 @@ public class PredicateGenerator {
     public static final Map<Class<? extends Query.Criterion>, CriterionHandler> CUSTOM_HANDLERS =
         new ConcurrentHashMap<>();
 
-    private static final AtomicBoolean SERVICE_LOADERS_INITIALIZED = new AtomicBoolean(false);
+    public 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. A discovered
+     * handler never replaces one registered in code: the maps keep the first registration.
+     */
     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;
         }
     }
 
@@ -146,8 +317,10 @@ public class PredicateGenerator {
      * 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;
@@ -190,7 +363,7 @@ public class PredicateGenerator {
         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);
             }
         }
 
diff --git a/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/QueryPathContext.java b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/QueryPathContext.java
new file mode 100644
index 0000000000..5758615bac
--- /dev/null
+++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/QueryPathContext.java
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2024-2025 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.grails.orm.hibernate.query;
+
+import jakarta.persistence.criteria.Expression;
+import jakarta.persistence.criteria.From;
+import jakarta.persistence.criteria.Path;
+
+/**
+ * What a criterion or order handler sees of the query it converts for: the root of the query and the resolution
+ * of property paths the way the built-in criteria resolve them. {@code alias.property}, for an alias created with
+ * {@code createAlias}, or {@code association.property} becomes a path on the join GORM created for that
+ * association, so a custom criterion and a built-in one on the same association restrict the same row rather
+ * than each joining the association again.
+ *
+ * @since 8.1
+ */
+public interface QueryPathContext {
+
+    /**
+     * @return the root of the query or subquery the handler converts for
+     */
+    From<?, ?> getRoot();
+
+    /**
+     * @param path a property path: {@code property}, {@code association.property} or {@code alias.property}
+     * @return the path, or {@code null} when the property path resolves to an expression that is not a path
+     */
+    Path<?> getFullyQualifiedPath(String path);
+
+    /**
+     * @param path a property path, or the alias of a projection
+     * @return the expression for it: a path, or the expression an alias or projection stands for
+     */
+    Expression<?> getFullyQualifiedExpression(String path);
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/JpaCriteriaQueryCreatorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/JpaCriteriaQueryCreatorSpec.groovy
index 27c30b58a6..8eef510619 100644
--- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/JpaCriteriaQueryCreatorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/JpaCriteriaQueryCreatorSpec.groovy
@@ -29,6 +29,7 @@ import org.grails.datastore.mapping.query.Query
 import org.hibernate.query.criteria.JpaCriteriaQuery
 import org.grails.orm.hibernate.query.JpaCriteriaQueryCreator
 import org.grails.orm.hibernate.query.JpaQueryContext
+import org.grails.orm.hibernate.query.PredicateGenerator
 import org.grails.orm.hibernate.query.SqlGroupProjection
 import org.grails.orm.hibernate.query.SqlProjection
 import org.springframework.core.convert.support.DefaultConversionService
@@ -130,6 +131,55 @@ class JpaCriteriaQueryCreatorSpec extends HibernateGormDatastoreSpec {
         query != null
     }
 
+    def "test createQuery orders by what the handler of a Query.Order subclass returns, in its place"() {
+        given:
+        var entity = getPersistentEntity(JpaCriteriaQueryCreatorSpecPerson)
+        var detachedCriteria = new DetachedCriteria(JpaCriteriaQueryCreatorSpecPerson)
+        detachedCriteria.order(new JpaCriteriaQueryCreatorSpecLengthOrder())
+        detachedCriteria.order(Query.Order.asc("firstName"))
+        JpaQueryContext seen = null
+        PredicateGenerator.registerOrderHandler(JpaCriteriaQueryCreatorSpecLengthOrder) { cq, root, cb, order, context ->
+            seen = context
+            cb.desc(cb.length(context.getFullyQualifiedPath('firstName') as jakarta.persistence.criteria.Expression<String>))
+        }
+        var creator = new JpaCriteriaQueryCreator(new Query.ProjectionList(), criteriaBuilder, entity, detachedCriteria, new DefaultConversionService())
+
+        when:
+        JpaCriteriaQuery<?> query = creator.createQuery()
+
+        then: "the handler's order comes first and the property order after it"
+        query.orderList.size() == 2
+        !query.orderList[0].ascending
+        query.orderList[1].ascending
+
+        and: "the handler was given the context of the query"
+        seen != null
+        seen.root.is(query.roots.first())
+
+        cleanup:
+        PredicateGenerator.clearCustomOrderHandlers()
+    }
+
+    def "test createQuery leaves out an order whose handler returns null"() {
+        given:
+        var entity = getPersistentEntity(JpaCriteriaQueryCreatorSpecPerson)
+        var detachedCriteria = new DetachedCriteria(JpaCriteriaQueryCreatorSpecPerson)
+        detachedCriteria.order(new JpaCriteriaQueryCreatorSpecLengthOrder())
+        detachedCriteria.order(Query.Order.asc("firstName"))
+        PredicateGenerator.registerOrderHandler(JpaCriteriaQueryCreatorSpecLengthOrder) { cq, root, cb, order, context -> null }
+        var creator = new JpaCriteriaQueryCreator(new Query.ProjectionList(), criteriaBuilder, entity, detachedCriteria, new DefaultConversionService())
+
+        when:
+        JpaCriteriaQuery<?> query = creator.createQuery()
+
+        then:
+        query.orderList.size() == 1
+        query.orderList[0].ascending
+
+        cleanup:
+        PredicateGenerator.clearCustomOrderHandlers()
+    }
+
     def "test createQuery with group by"() {
         given:
         var entity = getPersistentEntity(JpaCriteriaQueryCreatorSpecPerson)
@@ -461,6 +511,14 @@ class JpaCriteriaQueryCreatorSpecPet implements GormEntity<JpaCriteriaQueryCreat
     JpaCriteriaQueryCreatorSpecPerson owner
 }
 
+/** An order that a registered handler converts. */
+class JpaCriteriaQueryCreatorSpecLengthOrder extends Query.Order {
+
+    JpaCriteriaQueryCreatorSpecLengthOrder() {
+        super('firstName')
+    }
+}
+
 @Entity
 class JpaCriteriaQueryCreatorSpecGraded implements GormEntity<JpaCriteriaQueryCreatorSpecGraded> {
     Long id
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/OrderHandlerDiscoverySpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/OrderHandlerDiscoverySpec.groovy
new file mode 100644
index 0000000000..8ee49ae317
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/OrderHandlerDiscoverySpec.groovy
@@ -0,0 +1,115 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package grails.gorm.tests.hibernatequery
+
+import java.util.concurrent.Callable
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.Future
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.TimeoutException
+
+import spock.lang.Specification
+
+import org.grails.datastore.mapping.query.Query
+import org.grails.orm.hibernate.query.PredicateGenerator
+
+/**
+ * The handler providers are discovered on first use. A query that arrives while another thread discovers them must
+ * wait for the discovery instead of deciding, with the handlers still unregistered, that an order has no handler.
+ * {@link OrderHandlerDiscoveryProvider} is on the test classpath through {@code META-INF/services} and blocks inside
+ * its discovery while the spec holds its gate.
+ */
+class OrderHandlerDiscoverySpec extends Specification {
+
+    void cleanup() {
+        OrderHandlerDiscoveryProvider.gate.countDown()
+        OrderHandlerDiscoveryProvider.gate = new CountDownLatch(0)
+        PredicateGenerator.clearCustomOrderHandlers()
+    }
+
+    void "a query that arrives while the providers load waits for them and gets the discovered order handler"() {
+        given: "discovery has not run, and the provider blocks until the spec releases it"
+        PredicateGenerator.clearCustomOrderHandlers()
+        OrderHandlerDiscoveryProvider.gate = new CountDownLatch(1)
+        OrderHandlerDiscoveryProvider.entered = new CountDownLatch(1)
+        ExecutorService executor = Executors.newFixedThreadPool(2)
+        Callable<PredicateGenerator.OrderHandler> lookup = { PredicateGenerator.orderHandler(OrderHandlerDiscoveryOrder) }
+
+        when: "one thread starts the discovery and blocks inside the provider, and a second asks for the handler meanwhile"
+        Future<PredicateGenerator.OrderHandler> first = executor.submit(lookup)
+        boolean providerEntered = OrderHandlerDiscoveryProvider.entered.await(10, TimeUnit.SECONDS)
+        Future<PredicateGenerator.OrderHandler> second = executor.submit(lookup)
+        boolean secondAnsweredDuringDiscovery = true
+        try {
+            second.get(500, TimeUnit.MILLISECONDS)
+        } catch (TimeoutException ignored) {
+            secondAnsweredDuringDiscovery = false
+        }
+        OrderHandlerDiscoveryProvider.gate.countDown()
+
+        then: "the second caller waited for the discovery, and both callers get the discovered handler"
+        providerEntered
+        !secondAnsweredDuringDiscovery
+        first.get(10, TimeUnit.SECONDS) != null
+        second.get(10, TimeUnit.SECONDS) != null
+
+        cleanup:
+        executor.shutdownNow()
+    }
+
+    void "a handler registered in code is kept over the discovered one"() {
+        given:
+        PredicateGenerator.clearCustomOrderHandlers()
+        PredicateGenerator.OrderHandler registered = { query, root, cb, order, context -> null }
+        PredicateGenerator.registerOrderHandler(OrderHandlerDiscoveryOrder, registered)
+
+        expect:
+        PredicateGenerator.orderHandler(OrderHandlerDiscoveryOrder).is(registered)
+    }
+
+}
+
+class OrderHandlerDiscoveryOrder extends Query.Order {
+
+    OrderHandlerDiscoveryOrder() {
+        super('discovered')
+    }
+
+}
+
+class OrderHandlerDiscoveryProvider implements PredicateGenerator.OrderHandlerProvider {
+
+    static volatile CountDownLatch gate = new CountDownLatch(0)
+    static volatile CountDownLatch entered = new CountDownLatch(0)
+
+    @Override
+    Class<? extends Query.Order> orderType() {
+        OrderHandlerDiscoveryOrder
+    }
+
+    @Override
+    PredicateGenerator.OrderHandler orderHandler() {
+        entered.countDown()
+        gate.await()
+        return { query, root, cb, order, context -> null } as PredicateGenerator.OrderHandler
+    }
+
+}
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/PredicateGeneratorSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/PredicateGeneratorSpec.groovy
index f09b8d817a..b03c93f316 100644
--- a/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/PredicateGeneratorSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/PredicateGeneratorSpec.groovy
@@ -59,6 +59,44 @@ class PredicateGeneratorSpec extends HibernateGormDatastoreSpec {
         predicateGenerator = new PredicateGenerator(cb, session.datastore.mappingContext.conversionService)
     }
 
+    def "test getPredicates gives a contextual criterion handler the query context"() {
+        given:
+        JpaQueryContext seen = null
+        PredicateGenerator.registerCriterionHandler(PredicateGeneratorSpecMarker, { q, r, cb, criterion, context ->
+            seen = context
+            cb.conjunction()
+        } as PredicateGenerator.ContextualCriterionHandler)
+
+        when:
+        def predicates = predicateGenerator.getPredicates(query, root, [new PredicateGeneratorSpecMarker()], fromProvider, personEntity)
+
+        then:
+        predicates.length == 1
+        seen.is(fromProvider)
+
+        cleanup:
+        PredicateGenerator.clearCustomCriterionHandlers()
+    }
+
+    def "test getPredicates still calls a criterion handler that takes no context"() {
+        given:
+        boolean called = false
+        PredicateGenerator.registerCriterionHandler(PredicateGeneratorSpecMarker) { q, r, cb, criterion ->
+            called = true
+            cb.conjunction()
+        }
+
+        when:
+        def predicates = predicateGenerator.getPredicates(query, root, [new PredicateGeneratorSpecMarker()], fromProvider, personEntity)
+
+        then:
+        predicates.length == 1
+        called
+
+        cleanup:
+        PredicateGenerator.clearCustomCriterionHandlers()
+    }
+
     def "test getPredicates with Equals criterion"() {
         given:
         List criteria = [new Query.Equals("firstName", "Bob")]
@@ -573,6 +611,10 @@ class PredicateGeneratorSpec extends HibernateGormDatastoreSpec {
     }
 }
 
+/** A criterion with no built-in handling, so only a registered handler converts it. */
+class PredicateGeneratorSpecMarker implements Query.Criterion {
+}
+
 @Entity
 class PredicateGeneratorSpecPerson implements GormEntity<PredicateGeneratorSpecPerson> {
     Long id
diff --git a/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy
index 01b510191c..ed9f672380 100644
--- a/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy
+++ b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderDirectSpec.groovy
@@ -21,6 +21,7 @@ package grails.orm
 import grails.gorm.DetachedCriteria
 import grails.gorm.annotation.Entity
 import grails.gorm.tests.HibernateGormDatastoreSpec
+import jakarta.persistence.criteria.Expression
 import org.grails.datastore.mapping.query.Query
 import org.grails.datastore.mapping.query.api.BuildableCriteria
 import org.grails.orm.hibernate.query.PredicateGenerator
@@ -411,6 +412,107 @@ class HibernateCriteriaBuilderDirectSpec extends HibernateGormDatastoreSpec {
         and: "the order still applies to the list"
         new DetachedCriteria(CriteriaTestEntity).build { order('amount', 'desc') }.list()*.name == ['D', 'C', 'B', 'A']
     }
+
+    void "a contextual criterion handler resolves an association path to the join GORM created for it"() {
+        given: "a parent with two children, and a handler that resolves its criterion's path through the context"
+        var parent = CriteriaTestEntity.findByName('A')
+        parent.addToChildren(new CriteriaTestChild(name: 'Kid'))
+        parent.addToChildren(new CriteriaTestChild(name: 'Other')).save(flush: true)
+        PredicateGenerator.registerCriterionHandler(CriteriaTestPathLike, { query, root, cb, criterion, context ->
+            def c = criterion as CriteriaTestPathLike
+            cb.like(context.getFullyQualifiedPath(c.property) as Expression<String>, c.value as String)
+        } as PredicateGenerator.ContextualCriterionHandler)
+
+        when: "a GORM criterion and the custom criterion restrict the same association"
+        List<CriteriaTestEntity> both = new DetachedCriteria(CriteriaTestEntity).build {
+            children { eq('name', 'Kid') }
+            add(new CriteriaTestPathLike('children.name', 'K%'))
+        }.list()
+        List<CriteriaTestEntity> different = new DetachedCriteria(CriteriaTestEntity).build {
+            children { eq('name', 'Kid') }
+            add(new CriteriaTestPathLike('children.name', 'O%'))
+        }.list()
+
+        then: "a child matching both criteria is found"
+        both*.name == ['A']
+
+        and: "two children matching one criterion each are not: the handler used the join GORM created, not a second one"
+        different.empty
+
+        when: "the custom criterion comes before the association block"
+        List<CriteriaTestEntity> bothReversed = new DetachedCriteria(CriteriaTestEntity).build {
+            add(new CriteriaTestPathLike('children.name', 'K%'))
+            children { eq('name', 'Kid') }
+        }.list()
+        List<CriteriaTestEntity> differentReversed = new DetachedCriteria(CriteriaTestEntity).build {
+            add(new CriteriaTestPathLike('children.name', 'O%'))
+            children { eq('name', 'Kid') }
+        }.list()
+
+        then: "the handler creates the join from the discovered alias and the association block reuses it"
+        bothReversed*.name == ['A']
+        differentReversed.empty
+
+        cleanup:
+        PredicateGenerator.clearCustomCriterionHandlers()
+    }
+
+    void "an order handler converts a Query.Order subclass in its place among the orders"() {
+        given: "a handler that orders by the remainder of the amount modulo 15, descending"
+        PredicateGenerator.registerOrderHandler(CriteriaTestRemainderOrder) { query, root, cb, order, context ->
+            cb.desc(cb.mod(root.get('amount') as Expression<Integer>, 15))
+        }
+
+        when: "the custom order comes before an order by name"
+        List<String> names = c.list {
+            order(new CriteriaTestRemainderOrder())
+            order('name', 'asc')
+        }*.name
+
+        then: "amounts 10, 20, 30 and 40 give remainders 10, 5, 0 and 10, and the name breaks the tie"
+        names == ['A', 'D', 'B', 'C']
+
+        when: "the same orders go through a detached criteria"
+        List<String> detached = new DetachedCriteria(CriteriaTestEntity).build {
+            order(new CriteriaTestRemainderOrder())
+            order('name', 'asc')
+        }.list()*.name
+
+        then: "the handler converts the order there too"
+        detached == ['A', 'D', 'B', 'C']
+
+        and: "a count with the handled order counts every row, because a count query leaves its orders out"
+        new DetachedCriteria(CriteriaTestEntity).build { order(new CriteriaTestRemainderOrder()) }.count() == 4L
+
+        when: "the handler returns null"
+        PredicateGenerator.registerOrderHandler(CriteriaTestRemainderOrder) { query, root, cb, order, context -> null }
+        names = c.list {
+            order(new CriteriaTestRemainderOrder())
+            order('name', 'desc')
+        }*.name
+
+        then: "its order is left out and the others still apply"
+        names == ['D', 'C', 'B', 'A']
+
+        cleanup:
+        PredicateGenerator.clearCustomOrderHandlers()
+    }
+}
+
+/** A criterion whose handler resolves the property through the query context. */
+class CriteriaTestPathLike extends Query.PropertyCriterion {
+
+    CriteriaTestPathLike(String property, String value) {
+        super(property, value)
+    }
+}
+
+/** An order that a registered handler converts. */
+class CriteriaTestRemainderOrder extends Query.Order {
+
+    CriteriaTestRemainderOrder() {
+        super('amount')
+    }
 }
 
 @Entity
diff --git a/grails-data-hibernate7/core/src/test/resources/META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider b/grails-data-hibernate7/core/src/test/resources/META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider
new file mode 100644
index 0000000000..59a91c3266
--- /dev/null
+++ b/grails-data-hibernate7/core/src/test/resources/META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider
@@ -0,0 +1,19 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+#
+grails.gorm.tests.hibernatequery.OrderHandlerDiscoveryProvider
diff --git a/grails-data-hibernate7/docs/src/docs/asciidoc/querying/criteria.adoc b/grails-data-hibernate7/docs/src/docs/asciidoc/querying/criteria.adoc
index 3dbdb5ab36..6c6d315da1 100644
--- a/grails-data-hibernate7/docs/src/docs/asciidoc/querying/criteria.adoc
+++ b/grails-data-hibernate7/docs/src/docs/asciidoc/querying/criteria.adoc
@@ -387,6 +387,94 @@ def results = Airport.withCriteria {
 the `flights` collection would be loaded eagerly via a join even though the fetch mode has not been explicitly set.
 
 
+==== Extending the Criteria Builder
+
+A plugin or an application can add its own criteria and orders to the Hibernate 7 criteria builder. A custom
+criterion is a `org.grails.datastore.mapping.query.Query.Criterion`, usually a `Query.PropertyCriterion` subclass,
+that a registered _criterion handler_ turns into a JPA `Predicate` when the query runs. A custom order is a
+`Query.Order` subclass that a registered _order handler_ turns into a JPA `Order`, which can order by any
+expression, not only by a property. Both are usually added to the builder through a Groovy extension module, so
+that they read like the built-in ones:
+
+[source,groovy]
+----
+class NumberLikeCriterion extends Query.PropertyCriterion {
+    NumberLikeCriterion(String property, String pattern) { super(property, pattern) }
+}
+
+class ByRandom extends Query.Order {
+    ByRandom() { super('random') }
+}
+
+@CompileStatic
+class MyCriteriaExtensions {
+    static AbstractDetachedCriteria numberLike(AbstractDetachedCriteria self, String property, String pattern) {
+        self.add(new NumberLikeCriterion(property, pattern))
+        self
+    }
+
+    static AbstractDetachedCriteria orderByRandom(AbstractDetachedCriteria self) {
+        self.order(new ByRandom())
+        self
+    }
+}
+----
+
+The handlers convert them. A criterion handler is given the query, its root, the Hibernate `CriteriaBuilder`, the
+criterion and the query's `QueryPathContext`. Through the context, `getFullyQualifiedPath` resolves a property path
+the way the built-in criteria do: `alias.property`, for an alias created with `createAlias`, or `association.property`
+becomes a path on the join GORM created for that association, so a custom criterion and a built-in one on the
+same association restrict the same row rather than each joining the association again. An order handler is given
+the query, its root, the criteria builder, the order and the context, and returns the JPA order, or `null` to leave
+the order out. A query whose result is a count leaves every order out by itself, so no handler runs for it:
+
+[source,groovy]
+----
+PredicateGenerator.registerCriterionHandler(NumberLikeCriterion, { query, root, cb, criterion, context ->
+    def c = criterion as NumberLikeCriterion
+    cb.like(cb.cast(context.getFullyQualifiedPath(c.property) as JpaExpression, String), c.value as String)
+} as PredicateGenerator.ContextualCriterionHandler)
+
+PredicateGenerator.registerOrderHandler(ByRandom) { query, root, cb, order, context ->
+    cb.asc(cb.function('random', Double))
+}
+----
+
+A criterion handler that does not need the context takes four parameters and is coerced to
+`PredicateGenerator.CriterionHandler`. Handlers are registered for the exact class of the criterion or order, and
+a registered handler wins over the built-in handling, so a handler for `Query.Like` replaces how `like` is built.
+
+A plugin registers its handlers without any code in the application by implementing
+`PredicateGenerator.CriterionHandlerProvider` or `PredicateGenerator.OrderHandlerProvider` and naming the class in
+`META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$CriterionHandlerProvider` or
+`META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider`. The providers are
+loaded when the first criteria query runs; a handler registered in code takes precedence over a provider's.
+
+[source,groovy]
+----
+class ByRandomOrderHandlerProvider implements PredicateGenerator.OrderHandlerProvider {
+
+    Class<? extends Query.Order> orderType() { ByRandom }
+
+    PredicateGenerator.OrderHandler orderHandler() {
+        { query, root, cb, order, context ->
+            cb.asc(cb.function('random', Double))
+        } as PredicateGenerator.OrderHandler
+    }
+}
+----
+
+With the extension module and the providers on the classpath, a query uses them like any other criterion or order:
+
+[source,groovy]
+----
+new DetachedCriteria(Book).build {
+    numberLike 'price', '4%'
+    orderByRandom()
+}.list()
+----
+
+
 ==== Method Reference
 
 
diff --git a/grails-doc/src/en/guide/introduction/whatsNew.adoc b/grails-doc/src/en/guide/introduction/whatsNew.adoc
index a864d817b9..ed50151331 100644
--- a/grails-doc/src/en/guide/introduction/whatsNew.adoc
+++ b/grails-doc/src/en/guide/introduction/whatsNew.adoc
@@ -59,6 +59,16 @@ Jackson `JsonMapper`, so a Grails application and a Spring Boot application retu
 render. See the Date and Time Rendering sections of the REST and JSON Views guides, and the upgrade guide for the
 types that render differently than in Grails 7.
 
+==== Hibernate 7 Criteria Queries Can Be Extended
+
+A plugin or an application can add its own criteria and orders to the Hibernate 7 criteria builder. A criterion
+handler now receives the query context, through which it resolves a property path, whether through an alias created
+with `createAlias` or an association, to the join GORM created for it, so a custom criterion and a built-in one on
+the same association restrict the same row. A new order handler turns a `Query.Order` subclass into a JPA order by
+any expression, such as `random()` or a SQL formula, in its place among the query's orders. Both kinds of handler
+are registered in code, or by a plugin through `META-INF/services` providers that are loaded when the first query
+runs. See the section on extending the criteria builder in the GORM for Hibernate 7 guide.
+
 ==== Hibernate 7 Application Generation
 
 Grails Forge can now generate applications configured for Grails Data with Hibernate 7.
diff --git a/grails-test-examples/hibernate7/criteria-extension/grails-app/services/example/ProductSearchService.groovy b/grails-test-examples/hibernate7/criteria-extension/grails-app/services/example/ProductSearchService.groovy
index d561a27213..cc5bd85663 100644
--- a/grails-test-examples/hibernate7/criteria-extension/grails-app/services/example/ProductSearchService.groovy
+++ b/grails-test-examples/hibernate7/criteria-extension/grails-app/services/example/ProductSearchService.groovy
@@ -62,4 +62,15 @@ class ProductSearchService {
             numberLike 'price', pricePattern
         }.list() as List<Product>
     }
+
+    /**
+     * Lists every product, the one priced nearest to {@code target} first, through the
+     * {@code orderByPriceDistance} extension and the order handler behind it.
+     */
+    @ReadOnly
+    List<Product> findNearestPrice(BigDecimal target) {
+        new DetachedCriteria(Product).build {
+            orderByPriceDistance target
+        }.list() as List<Product>
+    }
 }
diff --git a/grails-test-examples/hibernate7/criteria-extension/src/integration-test/groovy/example/CriteriaExtensionSpec.groovy b/grails-test-examples/hibernate7/criteria-extension/src/integration-test/groovy/example/CriteriaExtensionSpec.groovy
index 8ff1028e7b..88e75e5c10 100644
--- a/grails-test-examples/hibernate7/criteria-extension/src/integration-test/groovy/example/CriteriaExtensionSpec.groovy
+++ b/grails-test-examples/hibernate7/criteria-extension/src/integration-test/groovy/example/CriteriaExtensionSpec.groovy
@@ -244,4 +244,40 @@ class CriteriaExtensionSpec extends Specification {
         results.size() == 2
         results*.name.sort() == ['Doohickey', 'Gadget']
     }
+
+    void "orderByPriceDistance lists the products nearest to the target price first"() {
+        given:
+        createProducts()
+
+        when: 'prices 9.99, 49.99 and 4.99 are measured from 10'
+        List<Product> results = new DetachedCriteria(Product).build {
+            orderByPriceDistance 10.00
+        }.list()
+
+        then:
+        results*.name == ['Widget', 'Doohickey', 'Gadget']
+    }
+
+    void "orderByPriceDistance keeps its place among the orders of the query"() {
+        given:
+        createProducts()
+        new Product(name: 'Gizmo', price: 10.01).save(flush: true)
+
+        when: 'Widget and Gizmo are both 0.01 from the target, and the name breaks the tie'
+        List<Product> results = new DetachedCriteria(Product).build {
+            orderByPriceDistance 10.00
+            order 'name', 'desc'
+        }.list()
+
+        then:
+        results*.name == ['Widget', 'Gizmo', 'Doohickey', 'Gadget']
+    }
+
+    void "ProductSearchService.findNearestPrice orders through the order handler"() {
+        given:
+        createProducts()
+
+        expect:
+        productSearchService.findNearestPrice(50.00)*.name == ['Gadget', 'Widget', 'Doohickey']
+    }
 }
diff --git a/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/extensions/HibernateCriteriaBuilderExtensions.groovy b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/extensions/HibernateCriteriaBuilderExtensions.groovy
index e556409076..8b8ba9de0b 100644
--- a/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/extensions/HibernateCriteriaBuilderExtensions.groovy
+++ b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/extensions/HibernateCriteriaBuilderExtensions.groovy
@@ -20,6 +20,7 @@
 package example.extensions
 
 import example.query.NumberLikeCriterion
+import example.query.PriceDistanceOrder
 import groovy.transform.CompileStatic
 import org.grails.datastore.gorm.query.criteria.AbstractDetachedCriteria
 
@@ -59,4 +60,19 @@ class HibernateCriteriaBuilderExtensions {
         self.add(new NumberLikeCriterion(propertyName, propertyValue as String))
         self
     }
+
+    /**
+     * Orders the results by how far their price is from {@code target}, nearest first.
+     *
+     * <p>Adds a {@link PriceDistanceOrder}, which the registered
+     * {@code OrderHandler} converts to an order by {@code abs(price - target)} at query execution time.
+     *
+     * @param self   the detached criteria
+     * @param target the price to measure the distance from
+     * @return the criteria for chaining
+     */
+    static AbstractDetachedCriteria orderByPriceDistance(AbstractDetachedCriteria self, BigDecimal target) {
+        self.order(new PriceDistanceOrder(target))
+        self
+    }
 }
diff --git a/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/NumberLikeCriterionHandlerProvider.groovy b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/NumberLikeCriterionHandlerProvider.groovy
index 94fe21f32b..924ca6cd7e 100644
--- a/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/NumberLikeCriterionHandlerProvider.groovy
+++ b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/NumberLikeCriterionHandlerProvider.groovy
@@ -37,12 +37,16 @@ class NumberLikeCriterionHandlerProvider implements PredicateGenerator.Criterion
         NumberLikeCriterion
     }
 
+    /**
+     * A contextual handler: the property is resolved through the query context, so {@code numberLike} also
+     * works on an association path such as {@code 'supplier.rating'}, on the join GORM created for it.
+     */
     @Override
     PredicateGenerator.CriterionHandler criterionHandler() {
-        { query, root, cb, criterion ->
+        { query, root, cb, criterion, context ->
             NumberLikeCriterion nlc = criterion as NumberLikeCriterion
-            def cast = cb.cast(root.get(nlc.property) as JpaExpression, String)
+            def cast = cb.cast(context.getFullyQualifiedPath(nlc.property) as JpaExpression, String)
             cb.like(cast, nlc.value as String)
-        } as PredicateGenerator.CriterionHandler
+        } as PredicateGenerator.ContextualCriterionHandler
     }
 }
diff --git a/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/PriceDistanceOrder.groovy b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/PriceDistanceOrder.groovy
new file mode 100644
index 0000000000..3409121c13
--- /dev/null
+++ b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/PriceDistanceOrder.groovy
@@ -0,0 +1,40 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package example.query
+
+import groovy.transform.CompileStatic
+import org.grails.datastore.mapping.query.Query
+
+/**
+ * An order by how far a product's price is from a target price, nearest first. It is a
+ * {@link Query.Order} subclass, so it is given to a criteria query with {@code order(...)}, and the
+ * {@link org.grails.orm.hibernate.query.PredicateGenerator.OrderHandler} that
+ * {@link PriceDistanceOrderHandlerProvider} registers turns it into a JPA order by an expression,
+ * which an order by a property name cannot express.
+ */
+@CompileStatic
+class PriceDistanceOrder extends Query.Order {
+
+    final BigDecimal target
+
+    PriceDistanceOrder(BigDecimal target) {
+        super('price')
+        this.target = target
+    }
+}
diff --git a/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/NumberLikeCriterionHandlerProvider.groovy b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/PriceDistanceOrderHandlerProvider.groovy
similarity index 52%
copy from grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/NumberLikeCriterionHandlerProvider.groovy
copy to grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/PriceDistanceOrderHandlerProvider.groovy
index 94fe21f32b..f47f6b958c 100644
--- a/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/NumberLikeCriterionHandlerProvider.groovy
+++ b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/PriceDistanceOrderHandlerProvider.groovy
@@ -16,33 +16,34 @@
  *  specific language governing permissions and limitations
  *  under the License.
  */
-
 package example.query
 
+import jakarta.persistence.criteria.Expression
 import org.grails.datastore.mapping.query.Query
 import org.grails.orm.hibernate.query.PredicateGenerator
-import org.hibernate.query.criteria.JpaExpression
 
 /**
- * ServiceLoader provider that registers the {@link NumberLikeCriterion} handler with
- * {@link PredicateGenerator} at first query execution — no BootStrap registration needed.
+ * ServiceLoader provider that registers the handler of {@link PriceDistanceOrder} with
+ * {@link PredicateGenerator} at first query execution. The handler orders by the absolute difference
+ * between the price and the target, resolving the {@code price} path through the query context the
+ * way the built-in orders do.
  *
  * <p>Discovered via
- * {@code META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$CriterionHandlerProvider}.
+ * {@code META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider}.
  */
-class NumberLikeCriterionHandlerProvider implements PredicateGenerator.CriterionHandlerProvider {
+class PriceDistanceOrderHandlerProvider implements PredicateGenerator.OrderHandlerProvider {
 
     @Override
-    Class<NumberLikeCriterion> criterionType() {
-        NumberLikeCriterion
+    Class<? extends Query.Order> orderType() {
+        PriceDistanceOrder
     }
 
     @Override
-    PredicateGenerator.CriterionHandler criterionHandler() {
-        { query, root, cb, criterion ->
-            NumberLikeCriterion nlc = criterion as NumberLikeCriterion
-            def cast = cb.cast(root.get(nlc.property) as JpaExpression, String)
-            cb.like(cast, nlc.value as String)
-        } as PredicateGenerator.CriterionHandler
+    PredicateGenerator.OrderHandler orderHandler() {
+        { query, root, cb, order, context ->
+            PriceDistanceOrder byDistance = order as PriceDistanceOrder
+            Expression<BigDecimal> price = context.getFullyQualifiedPath(byDistance.property) as Expression<BigDecimal>
+            cb.asc(cb.abs(cb.diff(price, byDistance.target)))
+        } as PredicateGenerator.OrderHandler
     }
 }
diff --git a/grails-test-examples/hibernate7/criteria-extension/src/main/resources/META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider b/grails-test-examples/hibernate7/criteria-extension/src/main/resources/META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider
new file mode 100644
index 0000000000..c54a3cfae5
--- /dev/null
+++ b/grails-test-examples/hibernate7/criteria-extension/src/main/resources/META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider
@@ -0,0 +1 @@
+example.query.PriceDistanceOrderHandlerProvider