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 707818e86e9..3d80038afea 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 @@ 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 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) expression)) : - criteriaBuilder.desc(criteriaBuilder.lower((Expression) 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 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) expression)) : + criteriaBuilder.desc(criteriaBuilder.lower((Expression) 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 81061df2960..549ae1cdafe 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 @@ * * @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 void setParent(JpaQueryContext parent) { this.parent = parent; } + @Override public From getRoot() { return joinTracker.getRoot(); } @@ -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); } @@ -173,6 +176,7 @@ public Expression getAliasedExpression(String alias) { return expr; } + @Override public Expression getFullyQualifiedExpression(String path) { if (parent != null) { if ("{alias}".equals(path)) { @@ -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)) { 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 163c18a79aa..b554412dfe4 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,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; @@ -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}: + *
+     *     PredicateGenerator.registerCriterionHandler(MyCriterion) { query, root, cb, criterion, context ->
+     *         def c = criterion as MyCriterion
+     *         cb.like(context.getFullyQualifiedPath(c.property) as Expression<String>, c.value as String)
+     *     }
+     * 
+ * + * @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. + * + *
+     *     class ByRandom extends Query.Order {
+     *         ByRandom() { super('random') }
+     *     }
+     *     PredicateGenerator.registerOrderHandler(ByRandom) { query, root, cb, order, context ->
+     *         cb.asc(cb.function('random', Double))
+     *     }
+     * 
+ * + * @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 orderType(); + + OrderHandler orderHandler(); } /** @@ -119,13 +238,69 @@ public interface CriterionHandlerProvider { public static final Map, CriterionHandler> CUSTOM_HANDLERS = new ConcurrentHashMap<>(); - private static final AtomicBoolean SERVICE_LOADERS_INITIALIZED = new AtomicBoolean(false); + private static final Map, 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 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 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; } } @@ -141,13 +316,36 @@ public static void registerCriterionHandler(Class 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 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; @@ -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); } } 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 00000000000..da46b99be54 --- /dev/null +++ b/grails-data-hibernate7/core/src/main/groovy/org/grails/orm/hibernate/query/QueryPathContext.java @@ -0,0 +1,71 @@ +/* + * 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 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); + + /** + * Resolves a path the way the built-in criteria resolve the property of a restriction, so a restriction on + * {@code height} restricts the property whatever a projection is called. + * + * @param path a property path, or an alias that the translator registers for an association or a projection of + * the query's own, such as a function + * @return the expression for it: a path, or the expression such an alias stands for; {@code null} when nothing + * resolves. The column alias of a {@code sqlProjection} or {@code sqlGroupProjection} is not resolved here, + * as SQL scopes a select alias to the order by and never to the where: ask {@link #getSelectionAlias(String)} + * for it + */ + Expression getFullyQualifiedExpression(String path); + + /** + * Returns the projection a column alias stands for, which is what the built-in {@code order(alias)} sorts by: an + * order handler that sorts by a column alias checks this first and {@link #getFullyQualifiedExpression(String)} + * second, and so orders by the projection when an alias and a property have the same name, as {@code order(alias)} + * does. + * + * @param alias the column alias of a {@code sqlProjection} or {@code sqlGroupProjection} of the query + * @return the projected expression recorded under that alias, so that a handler can order by it, or {@code null} + * when no projection of the query carries the alias + */ + Expression getSelectionAlias(String alias); +} 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 0ab0a6171df..f4a2c9aa8ad 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.grails.orm.hibernate.query.SqlSyntax @@ -131,6 +132,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)) + } + 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) @@ -462,6 +512,14 @@ class JpaCriteriaQueryCreatorSpecPet implements GormEntity { 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 00000000000..a1c31c55e7d --- /dev/null +++ b/grails-data-hibernate7/core/src/test/groovy/grails/gorm/tests/hibernatequery/OrderHandlerDiscoverySpec.groovy @@ -0,0 +1,120 @@ +/* + * 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 lookup = { PredicateGenerator.orderHandler(OrderHandlerDiscoveryOrder) } + + when: "one thread starts the discovery and blocks inside the provider, and a second asks for the handler meanwhile" + Future first = executor.submit(lookup) + boolean providerEntered = OrderHandlerDiscoveryProvider.entered.await(10, TimeUnit.SECONDS) + Future 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 orderType() { + OrderHandlerDiscoveryOrder + } + + @Override + PredicateGenerator.OrderHandler orderHandler() { + entered.countDown() + try { + gate.await() + } catch (InterruptedException e) { + Thread.currentThread().interrupt() + throw new IllegalStateException('Interrupted while the test held the provider', e) + } + 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 f09b8d817a0..b03c93f3167 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 { 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 01b510191ce..8afbaa2d990 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,170 @@ 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, c.value as String) + } as PredicateGenerator.ContextualCriterionHandler) + + when: "a GORM criterion and the custom criterion restrict the same association" + List both = new DetachedCriteria(CriteriaTestEntity).build { + children { eq('name', 'Kid') } + add(new CriteriaTestPathLike('children.name', 'K%')) + }.list() + List 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 bothReversed = new DetachedCriteria(CriteriaTestEntity).build { + add(new CriteriaTestPathLike('children.name', 'K%')) + children { eq('name', 'Kid') } + }.list() + List 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 "a contextual criterion handler resolves the alias of createAlias to the join GORM created for it"() { + given: "a parent with two children, and the same handler as above" + 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, c.value as String) + } + + when: "a createAlias alias is restricted by a GORM criterion and by the custom criterion" + List both = new DetachedCriteria(CriteriaTestEntity).build { + createAlias('children', 'ch') + eq('ch.name', 'Kid') + add(new CriteriaTestPathLike('ch.name', 'K%')) + }.list() + List different = new DetachedCriteria(CriteriaTestEntity).build { + createAlias('children', 'ch') + eq('ch.name', 'Kid') + add(new CriteriaTestPathLike('ch.name', 'O%')) + }.list() + List inBuilder = c.list { + createAlias('children', 'ch') + eq('ch.name', 'Kid') + hibernateQuery.add(new CriteriaTestPathLike('ch.name', 'O%')) + } + + then: "the alias shares the join, in a detached criteria and in the builder" + both*.name == ['A'] + different.empty + inBuilder.empty + + cleanup: + PredicateGenerator.clearCustomCriterionHandlers() + } + + void "a closure registered as a criterion handler is a contextual handler with five parameters and a plain one otherwise"() { + given: "one criterion handled by a four parameter closure and one by a five parameter closure, with no as" + PredicateGenerator.registerCriterionHandler(CriteriaTestPathLike) { query, root, cb, criterion -> + def c = criterion as CriteriaTestPathLike + cb.like(root.get(c.property) as Expression, c.value as String) + } + PredicateGenerator.registerCriterionHandler(CriteriaTestContextLike) { query, root, cb, criterion, context -> + def c = criterion as CriteriaTestContextLike + cb.like(context.getFullyQualifiedPath(c.property) as Expression, c.value as String) + } + + expect: + new DetachedCriteria(CriteriaTestEntity).build { add(new CriteriaTestPathLike('name', 'A%')) }.list()*.name == ['A'] + new DetachedCriteria(CriteriaTestEntity).build { add(new CriteriaTestContextLike('name', 'B%')) }.list()*.name == ['B'] + + 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, 15)) + } + + when: "the custom order comes before an order by name" + List 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 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) + } +} + +/** A criterion that a five parameter closure converts. */ +class CriteriaTestContextLike extends Query.PropertyCriterion { + + CriteriaTestContextLike(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/groovy/grails/orm/HibernateCriteriaBuilderSqlProjectionSpec.groovy b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderSqlProjectionSpec.groovy index 44a599890b7..1428d77271a 100644 --- a/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderSqlProjectionSpec.groovy +++ b/grails-data-hibernate7/core/src/test/groovy/grails/orm/HibernateCriteriaBuilderSqlProjectionSpec.groovy @@ -32,6 +32,8 @@ import spock.lang.Specification import grails.gorm.annotation.Entity import grails.gorm.transactions.Rollback +import org.grails.orm.hibernate.query.PredicateGenerator +import org.grails.datastore.mapping.query.Query import org.grails.datastore.mapping.core.DatastoreUtils import org.grails.orm.hibernate.HibernateDatastore import org.grails.orm.hibernate.cfg.Settings @@ -143,6 +145,86 @@ class HibernateCriteriaBuilderSqlProjectionSpec extends Specification { sqlCapture.statements.every { !it.contains('box width') } } + @Rollback + void 'an order handler orders by the expression a SQL projection records under its alias'() { + given: "a handler that orders by the selection an alias stands for, descending" + saveBoxes() + PredicateGenerator.registerOrderHandler(SqlProjectionAliasOrder) { query, root, cb, order, context -> + cb.desc(context.getSelectionAlias(order.property)) + } + + when: + List rows = SqlProjectionBox.createCriteria().list { + projections { + sqlProjection 'width * 10 as scaled', 'scaled', INTEGER + } + order(new SqlProjectionAliasOrder('scaled')) + } + + then: "the rows come in descending order of the projection, and the SQL orders by the select item, not by a column" + rows == rows.sort(false).reverse() + rows.size() == SqlProjectionBox.count() + sqlCapture.statements.any { it.contains('select width * 10 from') && it.contains('order by 1 desc') } + + cleanup: + PredicateGenerator.clearCustomOrderHandlers() + } + + @Rollback + void 'an order handler asks the selection alias first and the path second, and a criterion handler asks the path alone'() { + given: "an order handler that orders as the built-in order does, and a criterion handler that restricts as eq does" + saveBoxes() + List resolvedAsPath = [] + PredicateGenerator.registerOrderHandler(SqlProjectionAliasOrder) { query, root, cb, order, context -> + resolvedAsPath << context.getFullyQualifiedExpression(order.property) + cb.desc(context.getSelectionAlias(order.property) ?: context.getFullyQualifiedExpression(order.property)) + } + + when: "the alias names no property" + List rows = SqlProjectionBox.createCriteria().list { + projections { + sqlProjection 'width * 10 as scaled', 'scaled', INTEGER + } + order(new SqlProjectionAliasOrder('scaled')) + } + + then: "the path does not resolve it, the selection alias does, and the rows come in descending order of the projection" + resolvedAsPath == [null] + rows == rows.sort(false).reverse() + sqlCapture.statements.any { it.contains('select width * 10 from') && it.contains('order by 1 desc') } + + when: "the alias is the name of a property" + resolvedAsPath.clear() + sqlCapture.statements.clear() + SqlProjectionBox.createCriteria().list { + projections { + sqlProjection 'width * 10 as height', 'height', INTEGER + } + order(new SqlProjectionAliasOrder('height')) + } + + then: "the path resolves the property, and the handler still orders by the projection, as order(alias) does" + resolvedAsPath.size() == 1 + resolvedAsPath[0] != null + sqlCapture.statements.any { it.contains('order by 1 desc') } + + when: "a restriction on that name goes through the path" + sqlCapture.statements.clear() + List restricted = SqlProjectionBox.createCriteria().list { + projections { + sqlProjection 'width * 10 as height', 'height', INTEGER + } + eq('height', 9) + } + + then: "it restricts the property, not the projection, as in SQL" + restricted.size() == 2 + sqlCapture.statements.any { it.contains('.height=?') } + + cleanup: + PredicateGenerator.clearCustomOrderHandlers() + } + @Rollback void 'the results are ordered by the alias of a SQL projection'() { given: @@ -561,3 +643,11 @@ class SqlProjectionBox { Date packedAt SqlProjectionShelf shelf } + +/** An order that a registered handler converts, naming the column alias of a SQL projection. */ +class SqlProjectionAliasOrder extends Query.Order { + + SqlProjectionAliasOrder(String alias) { + super(alias) + } +} 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 00000000000..59a91c3266d --- /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 f2e07f92dee..911965c0259 100644 --- a/grails-data-hibernate7/docs/src/docs/asciidoc/querying/criteria.adoc +++ b/grails-data-hibernate7/docs/src/docs/asciidoc/querying/criteria.adoc @@ -399,6 +399,99 @@ 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. `getSelectionAlias` returns +the expression a `sqlProjection` or `sqlGroupProjection` records under its column alias. An order handler that sorts +by a column alias asks `getSelectionAlias` first and `getFullyQualifiedExpression` second, which orders by the +projection when an alias and a property have the same name, as `order(alias)` does; a criterion handler asks +`getFullyQualifiedExpression` alone, so a restriction on a property restricts the property, as in SQL, where a select +alias is visible to the `order by` and not to the `where`. 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) +} + +PredicateGenerator.registerOrderHandler(ByRandom) { query, root, cb, order, context -> + cb.asc(cb.function('random', Double)) +} +---- + +A closure with five parameters is registered as a handler that receives the context; a closure with four parameters +is registered as a handler that does not, so both shapes work without `as`. 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 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 a864d817b9a..ed50151331e 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 d561a27213f..cc5bd85663c 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 } + + /** + * Lists every product, the one priced nearest to {@code target} first, through the + * {@code orderByPriceDistance} extension and the order handler behind it. + */ + @ReadOnly + List findNearestPrice(BigDecimal target) { + new DetachedCriteria(Product).build { + orderByPriceDistance target + }.list() as List + } } 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 8ff1028e7b0..88e75e5c108 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 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 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 e5564090763..8b8ba9de0bd 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. + * + *

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 94fe21f32b9..924ca6cd7eb 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 00000000000..3409121c134 --- /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/PriceDistanceOrderHandlerProvider.groovy b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/PriceDistanceOrderHandlerProvider.groovy new file mode 100644 index 00000000000..f47f6b958cd --- /dev/null +++ b/grails-test-examples/hibernate7/criteria-extension/src/main/groovy/example/query/PriceDistanceOrderHandlerProvider.groovy @@ -0,0 +1,49 @@ +/* + * 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 jakarta.persistence.criteria.Expression +import org.grails.datastore.mapping.query.Query +import org.grails.orm.hibernate.query.PredicateGenerator + +/** + * 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. + * + *

Discovered via + * {@code META-INF/services/org.grails.orm.hibernate.query.PredicateGenerator$OrderHandlerProvider}. + */ +class PriceDistanceOrderHandlerProvider implements PredicateGenerator.OrderHandlerProvider { + + @Override + Class orderType() { + PriceDistanceOrder + } + + @Override + PredicateGenerator.OrderHandler orderHandler() { + { query, root, cb, order, context -> + PriceDistanceOrder byDistance = order as PriceDistanceOrder + Expression price = context.getFullyQualifiedPath(byDistance.property) as Expression + 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 00000000000..bf9be375b77 --- /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,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. +# +example.query.PriceDistanceOrderHandlerProvider