diff --git a/docs/tutorials/hibernate.md b/docs/tutorials/hibernate.md index dbca3f9fe4..bcdbedd637 100644 --- a/docs/tutorials/hibernate.md +++ b/docs/tutorials/hibernate.md @@ -154,6 +154,102 @@ List results = hibernateQuery.list(); This returns a Hibernate `org.hibernate.query.Query` rather than a JPA `jakarta.persistence.Query`. +## Common Table Expressions + +`HibernateQuery` supports Common Table Expressions (CTEs) via the `with()` +method family. CTEs define named temporary result sets that can be referenced +in the main query. This feature requires Hibernate 6.5 or later and is not +available on `JPAQuery`. + +| Method | Description | +|---|---| +| `with(alias, subquery)` | Define a CTE | +| `withMaterializedHint(alias, subquery)` | Define a CTE with `MATERIALIZED` hint | +| `withNotMaterializedHint(alias, subquery)` | Define a CTE with `NOT MATERIALIZED` hint | + +### Simple CTE with Join + +Define a CTE that selects a specific cat's weight, then join against it to +find lighter cats: + +```java +QCat cat = QCat.cat; +QCat felix = new QCat("felix"); + +Cat result = new HibernateQuery(session) + .withNotMaterializedHint(felix, + JPAExpressions.select(cat.bodyWeight.as(felix.bodyWeight)) + .from(cat) + .where(cat.name.eq("Felix"))) + .select(cat) + .from(felix) + .join(cat).on(cat.bodyWeight.lt(felix.bodyWeight)) + .orderBy(cat.bodyWeight.desc()) + .limit(1) + .fetchOne(); +``` + +Generated HQL: + +``` +with +felix as not materialized (select cat.bodyWeight as bodyWeight +from Cat cat +where cat.name = ?1) +select cat +from Cat cat, felix felix +where cat.bodyWeight > felix.bodyWeight +``` + +### CTE with Custom Column + +Use `Expressions.numberPath()` to create custom column references within a CTE: + +```java +QCat cat = QCat.cat; +QCat avgCat = new QCat("avgcat"); +NumberPath avgWeight = Expressions.numberPath(Double.class, avgCat, "avgweight"); + +List results = new HibernateQuery(session) + .with(avgCat, + JPAExpressions.select(cat.bodyWeight.avg().as(avgWeight)) + .from(cat)) + .select(cat) + .from(cat, avgCat) + .orderBy(cat.bodyWeight.subtract(avgWeight).abs().asc(), cat.id.asc()) + .fetch(); +``` + +### Multiple CTEs + +Chain `.with()` calls to define multiple CTEs. Later CTEs can reference earlier +ones: + +```java +QCat cat = QCat.cat; +QCat felix = new QCat("felix"); +QCat felixMates = new QCat("felixMates"); + +List results = new HibernateQuery(session) + .with(felix, + JPAExpressions.select(cat.id.as(felix.id)) + .from(cat) + .where(cat.name.eq("Felix"))) + .with(felixMates, + JPAExpressions.select(cat.id.as(cat.id)) + .from(cat) + .innerJoin(felix).on(cat.mate.id.eq(felix.id))) + .select(felixMates.id) + .from(felixMates) + .fetch(); +``` + +{: .note } +CTE aliases reuse existing Q-types (e.g., `new QCat("felix")`) to define CTE +columns. Use `as()` in projections to map columns from the subquery to the CTE +alias fields. The `JPAExpressions` factory is used for CTE subqueries, same as +for standard JPQL subqueries. + ## Native SQL with Hibernate Use `HibernateSQLQuery` to run native SQL through a Hibernate `Session`: @@ -173,6 +269,7 @@ for more details on native SQL query patterns. |---|---|---| | Underlying API | JPA EntityManager | Hibernate Session | | Query factory | `JPAQueryFactory` | `HibernateQueryFactory` | +| Common Table Expressions | Not available | `with()`, `withMaterializedHint()`, `withNotMaterializedHint()` | | Query caching | Not available | `setCacheable()`, `setCacheRegion()` | | Read-only mode | Not available | `setReadOnly()` | | SQL comments | Not available | `setComment()` | diff --git a/docs/tutorials/jpa.md b/docs/tutorials/jpa.md index af7e7b952b..e1666b89a4 100644 --- a/docs/tutorials/jpa.md +++ b/docs/tutorials/jpa.md @@ -58,7 +58,8 @@ The `JPAAnnotationProcessor` finds domain types annotated with the If you use Hibernate annotations in your domain types, use the processor `com.querydsl.apt.hibernate.HibernateAnnotationProcessor` instead. See the [Hibernate tutorial]({{ site.baseurl }}/tutorials/hibernate) for -Hibernate-specific features such as query caching and read-only mode. +Hibernate-specific features such as Common Table Expressions, query caching, +and read-only mode. Run `mvn clean install` and your query types will be generated into `target/generated-sources/java`. @@ -155,8 +156,9 @@ Both `JPAQuery` and `HibernateQuery` implement the `JPQLQuery` interface. For the examples in this chapter, queries are created via a `JPAQueryFactory` instance. `JPAQueryFactory` should be the preferred option for obtaining -`JPAQuery` instances. For the Hibernate API, `HibernateQueryFactory` can be used. For -Hibernate-specific features, see the +`JPAQuery` instances. For the Hibernate API, `HibernateQueryFactory` can be +used. For Hibernate-specific features such as CTEs, query caching, and +read-only mode, see the [Hibernate tutorial]({{ site.baseurl }}/tutorials/hibernate). To retrieve the customer with the first name Bob: diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JPQLSerializer.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JPQLSerializer.java index b089f4471d..926e33401f 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JPQLSerializer.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/JPQLSerializer.java @@ -135,7 +135,7 @@ private String getEntityName(Class clazz) { } } - private void handleJoinTarget(JoinExpression je) { + protected void handleJoinTarget(JoinExpression je) { // type specifier if (je.getTarget() instanceof EntityPath) { final EntityPath pe = (EntityPath) je.getTarget(); diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/AbstractHibernateQuery.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/AbstractHibernateQuery.java index 04fb83ebff..20f7733ed4 100644 --- a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/AbstractHibernateQuery.java +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/AbstractHibernateQuery.java @@ -17,16 +17,19 @@ import com.querydsl.core.DefaultQueryMetadata; import com.querydsl.core.NonUniqueResultException; import com.querydsl.core.QueryException; +import com.querydsl.core.QueryFlag; import com.querydsl.core.QueryMetadata; import com.querydsl.core.QueryModifiers; import com.querydsl.core.QueryResults; +import com.querydsl.core.types.ConstantImpl; import com.querydsl.core.types.Expression; +import com.querydsl.core.types.ExpressionUtils; import com.querydsl.core.types.FactoryExpression; import com.querydsl.core.types.Path; +import com.querydsl.core.types.SubQueryExpression; import com.querydsl.jpa.FactoryExpressionTransformer; import com.querydsl.jpa.HQLTemplates; import com.querydsl.jpa.JPAQueryBase; -import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.ScrollableResultsIterator; import java.util.HashMap; @@ -95,6 +98,29 @@ public long fetchCount() { } } + public Q with(Path alias, SubQueryExpression query) { + return with(alias, null, query); + } + + public Q withMaterializedHint(Path alias, SubQueryExpression query) { + return with(alias, true, query); + } + + public Q withNotMaterializedHint(Path alias, SubQueryExpression query) { + return with(alias, false, query); + } + + public Q with(Path alias, Boolean materialized, SubQueryExpression query) { + Expression expr = + ExpressionUtils.operation( + alias.getType(), + HQLOps.WITH, + alias, + materialized != null ? ConstantImpl.create(materialized) : null, + query); + return queryMixin.addFlag(new QueryFlag(QueryFlag.Position.WITH, expr)); + } + /** * Expose the original Hibernate query for the given projection * @@ -348,8 +374,8 @@ public T fetchOne() throws NonUniqueResultException { } @Override - protected JPQLSerializer createSerializer() { - return new JPQLSerializer(getTemplates()); + protected HQLSerializer createSerializer() { + return new HQLSerializer(getTemplates()); } protected void clone(Q query) { diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HQLOps.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HQLOps.java new file mode 100644 index 0000000000..04cfaed696 --- /dev/null +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HQLOps.java @@ -0,0 +1,18 @@ +package com.querydsl.jpa.hibernate; + +import com.querydsl.core.types.Operator; + +public enum HQLOps implements Operator { + WITH(Object.class); + + private final Class type; + + HQLOps(Class type) { + this.type = type; + } + + @Override + public Class getType() { + return type; + } +} diff --git a/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HQLSerializer.java b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HQLSerializer.java new file mode 100644 index 0000000000..8a65b7f224 --- /dev/null +++ b/querydsl-libraries/querydsl-jpa/src/main/java/com/querydsl/jpa/hibernate/HQLSerializer.java @@ -0,0 +1,82 @@ +package com.querydsl.jpa.hibernate; + +import com.querydsl.core.JoinExpression; +import com.querydsl.core.QueryFlag; +import com.querydsl.core.QueryMetadata; +import com.querydsl.core.types.ConstantImpl; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Operator; +import com.querydsl.core.types.Path; +import com.querydsl.jpa.JPQLSerializer; +import com.querydsl.jpa.JPQLTemplates; +import jakarta.persistence.EntityManager; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.jetbrains.annotations.Nullable; + +public class HQLSerializer extends JPQLSerializer { + + protected final Set> withAliases = new HashSet<>(); + + public HQLSerializer(JPQLTemplates templates) { + super(templates); + } + + public HQLSerializer(JPQLTemplates templates, EntityManager em) { + super(templates, em); + } + + @Override + public void serialize(QueryMetadata metadata, boolean forCountRow, @Nullable String projection) { + final Set flags = metadata.getFlags(); + final var hasFlags = !flags.isEmpty(); + + if (hasFlags) { + List> withFlags = new ArrayList<>(); + for (QueryFlag flag : flags) { + if (flag.getPosition() == QueryFlag.Position.WITH) { + withFlags.add(flag.getFlag()); + } + } + if (!withFlags.isEmpty()) { + append("with\n"); + handle(",\n", withFlags); + append("\n"); + } + } + + super.serialize(metadata, forCountRow, projection); + } + + @Override + protected void visitOperation( + Class type, Operator operator, List> args) { + if (operator == HQLOps.WITH && args.size() == 3 && args.get(0) instanceof Path alias) { + handle(alias); + withAliases.add(alias); + append(" as "); + if (args.get(1) instanceof ConstantImpl materializedParam + && materializedParam.getConstant() instanceof Boolean materialized) { + if (!materialized) { + append("not "); + } + append("materialized "); + } + handle(args.get(2)); + } else { + super.visitOperation(type, operator, args); + } + } + + @Override + protected void handleJoinTarget(JoinExpression je) { + if (je.getTarget() instanceof Path pe && withAliases.contains(pe)) { + append(pe.getMetadata().getName()).append(" "); + handle(je.getTarget()); + } else { + super.handleJoinTarget(je); + } + } +} diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateBase.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateBase.java index 33cadd4e09..37baceaaa7 100644 --- a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateBase.java +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateBase.java @@ -24,6 +24,8 @@ import com.querydsl.core.testutil.ExcludeIn; import com.querydsl.core.types.EntityPath; import com.querydsl.core.types.Expression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberPath; import com.querydsl.jpa.domain.Cat; import com.querydsl.jpa.domain.QCat; import com.querydsl.jpa.domain.QGroup; @@ -247,4 +249,97 @@ public void subQueryWithOffsetOnly() { assertThat(results).hasSize(expectedIds.size()); assertThat(results.stream().map(Cat::getId).toList()).isEqualTo(expectedIds); } + + @Test + public void cteWithInnerJoinAndHint() { + // does not work before Hibernate 6.5, + // see https://hibernate.atlassian.net/browse/HHH-17897 + + // find the heaviest cat that is lighter than Felix + var felix = new QCat("felix"); + Cat result = + query() + .withNotMaterializedHint( + felix, + JPAExpressions.select(QCat.cat.bodyWeight.as(felix.bodyWeight)) + .from(QCat.cat) + .where(QCat.cat.name.eq("Felix123"))) + .select(QCat.cat) + .from(felix) + .join(QCat.cat) + .on(QCat.cat.bodyWeight.lt(felix.bodyWeight)) + .orderBy(QCat.cat.bodyWeight.desc()) + .limit(1) + .fetchOne(); + assertThat(result) + .hasFieldOrPropertyWithValue("id", 2) + .hasFieldOrPropertyWithValue("name", "Ruth123"); + } + + @Test + public void cteWithCrossJoinAndCustomColumn() { + // all cats in ascending order by comparing their weight to the most average weight of all cats + var avgCat = new QCat("avgcat"); + NumberPath avgWeightColumn = Expressions.numberPath(Double.class, avgCat, "avgweight"); + List results = + query() + .with( + avgCat, + JPAExpressions.select(QCat.cat.bodyWeight.avg().as(avgWeightColumn)).from(QCat.cat)) + .select(QCat.cat) + .from(QCat.cat, avgCat) + .orderBy(QCat.cat.bodyWeight.subtract(avgWeightColumn).abs().asc(), QCat.cat.id.asc()) + .fetch(); + // the average body weights of all cats is 3.5 + assertThat(results) + .hasSize(6) + .satisfiesExactly( + cat -> + assertThat(cat) + .hasFieldOrPropertyWithValue("id", 3) + .hasFieldOrPropertyWithValue("bodyWeight", 3.0D), + cat -> + assertThat(cat) + .hasFieldOrPropertyWithValue("id", 4) + .hasFieldOrPropertyWithValue("bodyWeight", 4.0D), + cat -> + assertThat(cat) + .hasFieldOrPropertyWithValue("id", 2) + .hasFieldOrPropertyWithValue("bodyWeight", 2.0D), + cat -> + assertThat(cat) + .hasFieldOrPropertyWithValue("id", 5) + .hasFieldOrPropertyWithValue("bodyWeight", 5.0D), + cat -> + assertThat(cat) + .hasFieldOrPropertyWithValue("id", 1) + .hasFieldOrPropertyWithValue("bodyWeight", 1.0D), + cat -> + assertThat(cat) + .hasFieldOrPropertyWithValue("id", 6) + .hasFieldOrPropertyWithValue("bodyWeight", 6.0D)); + } + + @Test + public void multipleCtes() { + QCat felix = new QCat("felix"); + QCat felixMates = new QCat("felixMates"); + List results = + query() + .with( + felix, + JPAExpressions.select(QCat.cat.id.as(felix.id)) + .from(QCat.cat) + .where(QCat.cat.name.eq("Felix123"))) + .with( + felixMates, + JPAExpressions.select(QCat.cat.id.as(QCat.cat.id)) + .from(QCat.cat) + .innerJoin(felix) + .on(QCat.cat.mate.id.eq(felix.id))) + .select(felixMates.id) + .from(felixMates) + .fetch(); + assertThat(results).hasSize(1).isEqualTo(List.of(4)); + } } diff --git a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateQueryTest.java b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateQueryTest.java index 51bf45a1e8..ad5b93e6a3 100644 --- a/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateQueryTest.java +++ b/querydsl-libraries/querydsl-jpa/src/test/java/com/querydsl/jpa/HibernateQueryTest.java @@ -43,4 +43,63 @@ public void innerJoin() { assertThat(hqlQuery.toString()) .isEqualTo("select employee\nfrom Employee employee\n inner join employee.user as user"); } + + @Test + public void cteWithNotMaterializedHint() { + HibernateQuery query = new HibernateQuery<>(); + QCat felix = new QCat("felix"); + query + .withNotMaterializedHint( + felix, + JPAExpressions.select(QCat.cat.bodyWeight.as(felix.bodyWeight)) + .from(QCat.cat) + .where(QCat.cat.name.eq("Felix123"))) + .select(QCat.cat) + .from(QCat.cat, felix) + .where(QCat.cat.bodyWeight.gt(felix.bodyWeight)); + assertThat(query.toString()) + .isEqualTo( + """ + with + felix as not materialized (select cat.bodyWeight as bodyWeight + from Cat cat + where cat.name = ?1) + select cat + from Cat cat, felix felix + where cat.bodyWeight > felix.bodyWeight"""); + } + + @Test + public void multipleCtes() { + HibernateQuery query = new HibernateQuery<>(); + QCat felix = new QCat("felix"); + QCat felixMates = new QCat("felixMates"); + + query + .with( + felix, + JPAExpressions.select(QCat.cat.id.as(felix.id)) + .from(QCat.cat) + .where(QCat.cat.name.eq("Felix123"))) + .with( + felixMates, + JPAExpressions.select(QCat.cat.id.as(felixMates.id)) + .from(QCat.cat) + .innerJoin(felix) + .on(QCat.cat.mate.id.eq(felix.id))) + .select(felixMates.id) + .from(felixMates); + assertThat(query.toString()) + .isEqualTo( + """ + with + felix as (select cat.id as id + from Cat cat + where cat.name = ?1), + felixMates as (select cat.id as id + from Cat cat + inner join felix felix with cat.mate.id = felix.id) + select felixMates.id + from felixMates felixMates"""); + } }