diff --git a/Jenkinsfile b/Jenkinsfile
index 915e46ddb7..a088fae34e 100644
--- a/Jenkinsfile
+++ b/Jenkinsfile
@@ -9,7 +9,7 @@ pipeline {
 
 	triggers {
 		pollSCM 'H/10 * * * *'
-		upstream(upstreamProjects: "spring-data-commons/main", threshold: hudson.model.Result.SUCCESS)
+		upstream(upstreamProjects: "spring-data-commons/4.0.x", threshold: hudson.model.Result.SUCCESS)
 	}
 
 	options {
@@ -58,50 +58,6 @@ pipeline {
 			}
 
 			parallel {
-				stage("test: hibernate 6.2 (LTS)") {
-					agent {
-						label 'data'
-					}
-					options { timeout(time: 30, unit: 'MINUTES')}
-					environment {
-						ARTIFACTORY = credentials("${p['artifactory.credentials']}")
-						DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}")
-						TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor'
-					}
-					steps {
-						script {
-							docker.withRegistry(p['docker.proxy.registry'], p['docker.proxy.credentials']) {
-								docker.image(p['docker.java.next.image']).inside(p['docker.java.inside.docker']) {
-									sh "PROFILE=all-dbs,hibernate-62 " +
-										"JENKINS_USER_NAME=${p['jenkins.user.name']} " +
-										"ci/test.sh"
-								}
-							}
-						}
-					}
-				}
-				stage("test: baseline (hibernate 6.6 snapshots)") {
-					agent {
-						label 'data'
-					}
-					options { timeout(time: 30, unit: 'MINUTES')}
-					environment {
-						ARTIFACTORY = credentials("${p['artifactory.credentials']}")
-						DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}")
-						TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor'
-					}
-					steps {
-						script {
-							docker.withRegistry(p['docker.proxy.registry'], p['docker.proxy.credentials']) {
-								docker.image(p['docker.java.next.image']).inside(p['docker.java.inside.docker']) {
-									sh "PROFILE=all-dbs,hibernate-66-snapshots " +
-										"JENKINS_USER_NAME=${p['jenkins.user.name']} " +
-										"ci/test.sh"
-								}
-							}
-						}
-					}
-				}
 				stage("test: java.next (next)") {
 					agent {
 						label 'data'
diff --git a/org/antlr/v4/tool/templates/codegen/Java/Java.stg b/org/antlr/v4/tool/templates/codegen/Java/Java.stg
index fc455cfa1d..7f1701c00f 100644
--- a/org/antlr/v4/tool/templates/codegen/Java/Java.stg
+++ b/org/antlr/v4/tool/templates/codegen/Java/Java.stg
@@ -48,14 +48,18 @@ ParserFile(file, parser, namedActions, contextSuperClass) ::= <<
 package <file.genPackage>;
 <endif>
 <namedActions.header>
+
 import org.antlr.v4.runtime.atn.*;
 import org.antlr.v4.runtime.dfa.DFA;
 import org.antlr.v4.runtime.*;
 import org.antlr.v4.runtime.misc.*;
 import org.antlr.v4.runtime.tree.*;
+import org.jspecify.annotations.NullUnmarked;
 import java.util.List;
 import java.util.Iterator;
 import java.util.ArrayList;
+import jakarta.annotation.Generated;
+
 
 <parser>
 >>
@@ -67,11 +71,15 @@ package <file.genPackage>;
 <endif>
 <header>
 import org.antlr.v4.runtime.tree.ParseTreeListener;
+import org.jspecify.annotations.NullUnmarked;
+import jakarta.annotation.Generated;
 
 /**
  * This interface defines a complete listener for a parse tree produced by
  * {@link <file.parserName>}.
  */
+@NullUnmarked
+@Generated("<file.grammarName>Listener")
 interface <file.grammarName>Listener extends ParseTreeListener {
 	<file.listenerNames:{lname |
 /**
@@ -103,17 +111,20 @@ BaseListenerFile(file, header, namedActions) ::= <<
 package <file.genPackage>;
 <endif>
 <header>
-
 import org.antlr.v4.runtime.ParserRuleContext;
 import org.antlr.v4.runtime.tree.ErrorNode;
 import org.antlr.v4.runtime.tree.TerminalNode;
+import org.jspecify.annotations.NullUnmarked;
+import jakarta.annotation.Generated;
 
 /**
  * This class provides an empty implementation of {@link <file.grammarName>Listener},
  * which can be extended to create a listener which only needs to handle a subset
  * of the available methods.
  */
-@SuppressWarnings("CheckReturnValue")
+@NullUnmarked
+@Generated("<file.grammarName>BaseListener")
+@SuppressWarnings({ "CheckReturnValue", "NullAway" })
 class <file.grammarName>BaseListener implements <file.grammarName>Listener {
 	<file.listenerNames:{lname |
 /**
@@ -163,6 +174,8 @@ package <file.genPackage>;
 <endif>
 <header>
 import org.antlr.v4.runtime.tree.ParseTreeVisitor;
+import org.jspecify.annotations.NullUnmarked;
+import jakarta.annotation.Generated;
 
 /**
  * This interface defines a complete generic visitor for a parse tree produced
@@ -171,6 +184,8 @@ import org.antlr.v4.runtime.tree.ParseTreeVisitor;
  * @param \<T> The return type of the visit operation. Use {@link Void} for
  * operations with no return type.
  */
+@NullUnmarked
+@Generated("<file.grammarName>Visitor")
 interface <file.grammarName>Visitor\<T> extends ParseTreeVisitor\<T> {
 	<file.visitorNames:{lname |
 /**
@@ -194,6 +209,8 @@ package <file.genPackage>;
 <endif>
 <header>
 import org.antlr.v4.runtime.tree.AbstractParseTreeVisitor;
+import org.jspecify.annotations.NullUnmarked;
+import jakarta.annotation.Generated;
 
 /**
  * This class provides an empty implementation of {@link <file.grammarName>Visitor},
@@ -203,7 +220,9 @@ import org.antlr.v4.runtime.tree.AbstractParseTreeVisitor;
  * @param \<T> The return type of the visit operation. Use {@link Void} for
  * operations with no return type.
  */
-@SuppressWarnings("CheckReturnValue")
+@NullUnmarked
+@Generated("<file.grammarName>BaseVisitor")
+@SuppressWarnings({ "CheckReturnValue", "NullAway" })
 class <file.grammarName>BaseVisitor\<T> extends AbstractParseTreeVisitor\<T> implements <file.grammarName>Visitor\<T> {
 	<file.visitorNames:{lname |
 /**
@@ -225,7 +244,9 @@ Parser(parser, funcs, atn, sempredFuncs, superClass) ::= <<
 >>
 
 Parser_(parser, funcs, atn, sempredFuncs, ctor, superClass) ::= <<
-@SuppressWarnings({"all", "warnings", "unchecked", "unused", "cast", "CheckReturnValue"})
+@NullUnmarked
+@Generated("<parser.name>")
+@SuppressWarnings({"all", "warnings", "unchecked", "unused", "cast", "CheckReturnValue", "NullAway"})
 class <parser.name> extends <superClass; null="Parser"> {
 	// Customization: Suppress version check
 	// static { RuntimeMetaData.checkVersion("<file.ANTLRVersion>", RuntimeMetaData.VERSION); }
@@ -895,12 +916,16 @@ import org.antlr.v4.runtime.*;
 import org.antlr.v4.runtime.atn.*;
 import org.antlr.v4.runtime.dfa.DFA;
 import org.antlr.v4.runtime.misc.*;
+import org.jspecify.annotations.NullUnmarked;
+import jakarta.annotation.Generated;
 
 <lexer>
 >>
 
 Lexer(lexer, atn, actionFuncs, sempredFuncs, superClass) ::= <<
-@SuppressWarnings({"all", "warnings", "unchecked", "unused", "cast", "CheckReturnValue"})
+@NullUnmarked
+@Generated("<lexer.name>")
+@SuppressWarnings({"all", "warnings", "unchecked", "unused", "cast", "CheckReturnValue", "NullAway"})
 class <lexer.name> extends <superClass; null="Lexer"> {
 	// Customization: Suppress version check
 	// static { RuntimeMetaData.checkVersion("<lexerFile.ANTLRVersion>", RuntimeMetaData.VERSION); }
diff --git a/pom.xml b/pom.xml
index 8af40fe698..8abc92784d 100755
--- a/pom.xml
+++ b/pom.xml
@@ -5,7 +5,7 @@
 
 	<groupId>org.springframework.data</groupId>
 	<artifactId>spring-data-jpa-parent</artifactId>
-	<version>3.5.0-SNAPSHOT</version>
+	<version>4.0.0-GH-3877-SNAPSHOT</version>
 	<packaging>pom</packaging>
 
 	<name>Spring Data JPA Parent</name>
@@ -23,31 +23,27 @@
 	<parent>
 		<groupId>org.springframework.data.build</groupId>
 		<artifactId>spring-data-parent</artifactId>
-		<version>3.5.0-SNAPSHOT</version>
+		<version>4.0.0-SNAPSHOT</version>
 	</parent>
 
 	<properties>
-		<antlr>4.13.0</antlr> <!-- align with Hibernate's parser -->
-		<eclipselink>4.0.5</eclipselink>
-		<eclipselink-next>4.0.6-SNAPSHOT</eclipselink-next>
-		<hibernate>6.6.11.Final</hibernate>
-		<hibernate-62>6.2.33.Final</hibernate-62>
-		<hibernate-66-snapshots>6.6.12-SNAPSHOT</hibernate-66-snapshots>
-		<hibernate-70>7.0.0.Beta5</hibernate-70>
+		<antlr>4.13.2</antlr> <!-- align with Hibernate's parser -->
+		<eclipselink>5.0.0-B07</eclipselink>
+		<eclipselink-next>5.0.0-SNAPSHOT</eclipselink-next>
+		<hibernate>7.0.0.CR1</hibernate>
 		<hibernate-70-snapshots>7.0.0-SNAPSHOT</hibernate-70-snapshots>
 		<hsqldb>2.7.4</hsqldb>
 		<h2>2.3.232</h2>
-		<jakarta-persistence-api>3.1.0</jakarta-persistence-api>
+		<jakarta-persistence-api>3.2.0</jakarta-persistence-api>
 		<jsqlparser>5.0</jsqlparser>
 		<mysql-connector-java>9.1.0</mysql-connector-java>
 		<postgresql>42.7.4</postgresql>
-		<springdata.commons>3.5.0-SNAPSHOT</springdata.commons>
+		<springdata.commons>4.0.0-SNAPSHOT</springdata.commons>
 		<vavr>0.10.3</vavr>
 
 		<hibernate.groupId>org.hibernate</hibernate.groupId>
 
 		<sonar.dynamicAnalysis>reuseReports</sonar.dynamicAnalysis>
-
 	</properties>
 
 	<modules>
@@ -59,40 +55,11 @@
 
 	<profiles>
 		<profile>
-			<id>hibernate-62</id>
-			<properties>
-				<hibernate>${hibernate-62}</hibernate>
-			</properties>
-		</profile>
-		<profile>
-			<id>hibernate-66-snapshots</id>
-			<properties>
-				<hibernate>${hibernate-66-snapshots}</hibernate>
-			</properties>
+			<id>jmh</id>
 			<repositories>
 				<repository>
-					<id>sonatype-oss</id>
-					<url>https://oss.sonatype.org/content/repositories/snapshots</url>
-					<releases>
-						<enabled>false</enabled>
-					</releases>
-				</repository>
-			</repositories>
-		</profile>
-		<profile>
-			<id>hibernate-70</id>
-			<properties>
-				<hibernate>${hibernate-70}</hibernate>
-				<jakarta-persistence-api>3.2.0</jakarta-persistence-api>
-				<antlr>4.13.2</antlr>
-			</properties>
-			<repositories>
-				<repository>
-					<id>sonatype-oss</id>
-					<url>https://oss.sonatype.org/content/repositories/snapshots</url>
-					<releases>
-						<enabled>false</enabled>
-					</releases>
+					<id>jitpack</id>
+					<url>https://jitpack.io</url>
 				</repository>
 			</repositories>
 		</profile>
@@ -112,6 +79,44 @@
 				</repository>
 			</repositories>
 		</profile>
+		<profile>
+			<id>all-dbs</id>
+			<build>
+				<plugins>
+					<plugin>
+						<groupId>org.apache.maven.plugins</groupId>
+						<artifactId>maven-surefire-plugin</artifactId>
+						<executions>
+							<execution>
+								<id>mysql-test</id>
+								<phase>test</phase>
+								<goals>
+									<goal>test</goal>
+								</goals>
+								<configuration>
+									<includes>
+										<include>**/MySql*IntegrationTests.java</include>
+									</includes>
+								</configuration>
+							</execution>
+							<execution>
+								<id>postgres-test</id>
+								<phase>test</phase>
+								<goals>
+									<goal>test</goal>
+								</goals>
+								<configuration>
+									<includes>
+										<include>**/Postgres*IntegrationTests.java
+										</include>
+									</includes>
+								</configuration>
+							</execution>
+						</executions>
+					</plugin>
+				</plugins>
+			</build>
+		</profile>
 		<profile>
 			<id>eclipselink-next</id>
 			<properties>
@@ -149,9 +154,92 @@
 			<version>${spring}</version>
 			<scope>provided</scope>
 		</dependency>
+		<dependency>
+			<groupId>org.jboss.logging</groupId>
+			<artifactId>jboss-logging</artifactId>
+			<version>3.6.1.Final</version>
+		</dependency>
 	</dependencies>
 
 	<build>
+		<plugins>
+
+			<plugin>
+				<groupId>org.apache.maven.plugins</groupId>
+				<artifactId>maven-surefire-plugin</artifactId>
+				<dependencies>
+					<dependency>
+						<groupId>org.springframework</groupId>
+						<artifactId>spring-instrument</artifactId>
+						<version>${spring}</version>
+						<scope>runtime</scope>
+					</dependency>
+				</dependencies>
+				<executions>
+					<execution>
+						<!-- override the default-test execution and exclude everything -->
+						<id>default-test</id>
+						<configuration>
+							<excludes>
+								<exclude>**/*</exclude>
+							</excludes>
+						</configuration>
+					</execution>
+					<execution>
+						<id>unit-test</id>
+						<goals>
+							<goal>test</goal>
+						</goals>
+						<phase>test</phase>
+						<configuration>
+							<includes>
+								<include>**/*UnitTests.java</include>
+							</includes>
+						</configuration>
+					</execution>
+					<execution>
+						<id>integration-test</id>
+						<goals>
+							<goal>test</goal>
+						</goals>
+						<phase>test</phase>
+						<configuration>
+							<includes>
+								<include>**/*IntegrationTests.java</include>
+								<include>**/*Tests.java</include>
+							</includes>
+							<excludes>
+								<exclude>**/*UnitTests.java</exclude>
+								<exclude>**/EclipseLink*</exclude>
+								<exclude>**/MySql*</exclude>
+								<exclude>**/Postgres*</exclude>
+							</excludes>
+							<argLine>
+								-javaagent:${settings.localRepository}/org/springframework/spring-instrument/${spring}/spring-instrument-${spring}.jar
+							</argLine>
+						</configuration>
+					</execution>
+					<execution>
+						<id>eclipselink-test</id>
+						<goals>
+							<goal>test</goal>
+						</goals>
+						<phase>test</phase>
+						<configuration>
+							<includes>
+								<include>**/EclipseLink*Tests.java</include>
+							</includes>
+							<argLine>
+								-javaagent:${settings.localRepository}/org/eclipse/persistence/org.eclipse.persistence.jpa/${eclipselink}/org.eclipse.persistence.jpa-${eclipselink}.jar
+								-javaagent:${settings.localRepository}/org/springframework/spring-instrument/${spring}/spring-instrument-${spring}.jar
+							</argLine>
+						</configuration>
+					</execution>
+				</executions>
+			</plugin>
+
+		</plugins>
+
 		<pluginManagement>
 			<plugins>
 				<plugin>
diff --git a/spring-data-envers/pom.xml b/spring-data-envers/pom.xml
index 5dcbbb69fd..ff3b6b44b4 100755
--- a/spring-data-envers/pom.xml
+++ b/spring-data-envers/pom.xml
@@ -5,12 +5,12 @@
 
 	<groupId>org.springframework.data</groupId>
 	<artifactId>spring-data-envers</artifactId>
-	<version>3.5.0-SNAPSHOT</version>
+	<version>4.0.0-GH-3877-SNAPSHOT</version>
 
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-jpa-parent</artifactId>
-		<version>3.5.0-SNAPSHOT</version>
+		<version>4.0.0-GH-3877-SNAPSHOT</version>
 		<relativePath>../pom.xml</relativePath>
 	</parent>
 
@@ -60,6 +60,12 @@
 			<version>${project.version}</version>
 		</dependency>
 
+		<dependency>
+			<groupId>org.jboss.logging</groupId>
+			<artifactId>jboss-logging</artifactId>
+			<version>3.6.1.Final</version>
+		</dependency>
+
 		<!-- Hibernate -->
 		<dependency>
 			<groupId>org.hibernate.orm</groupId>
diff --git a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/config/package-info.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/config/package-info.java
index 2e79b25c03..ab7c7b3781 100644
--- a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/config/package-info.java
+++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/config/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Classes for Envers Repositories configuration support.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.envers.repository.config;
diff --git a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryFactoryBean.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryFactoryBean.java
index 9f40559ca4..b152bef044 100755
--- a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryFactoryBean.java
+++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryFactoryBean.java
@@ -15,11 +15,13 @@
  */
 package org.springframework.data.envers.repository.support;
 
-import java.util.Optional;
-
 import jakarta.persistence.EntityManager;
 
+import java.util.Optional;
+
 import org.hibernate.envers.DefaultRevisionEntity;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.beans.factory.FactoryBean;
 import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
 import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean;
@@ -38,7 +40,7 @@
 public class EnversRevisionRepositoryFactoryBean<T extends RevisionRepository<S, ID, N>, S, ID, N extends Number & Comparable<N>>
 		extends JpaRepositoryFactoryBean<T, S, ID> {
 
-	private Class<?> revisionEntityClass;
+	private @Nullable Class<?> revisionEntityClass;
 
 	/**
 	 * Creates a new {@link EnversRevisionRepositoryFactoryBean} for the given repository interface.
@@ -80,7 +82,7 @@ private static class RevisionRepositoryFactory<T, ID, N extends Number & Compara
 		 * @param entityManager must not be {@literal null}.
 		 * @param revisionEntityClass can be {@literal null}, will default to {@link DefaultRevisionEntity}.
 		 */
-		public RevisionRepositoryFactory(EntityManager entityManager, Class<?> revisionEntityClass) {
+		public RevisionRepositoryFactory(EntityManager entityManager, @Nullable Class<?> revisionEntityClass) {
 
 			super(entityManager);
 
@@ -94,7 +96,7 @@ public RevisionRepositoryFactory(EntityManager entityManager, Class<?> revisionE
 		@Override
 		protected RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) {
 
-			Object fragmentImplementation = getTargetRepositoryViaReflection( //
+			Object fragmentImplementation = instantiateClass( //
 					EnversRevisionRepositoryImpl.class, //
 					getEntityInformation(metadata.getDomainType()), //
 					revisionEntityInformation, //
diff --git a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/package-info.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/package-info.java
index dd135fdacf..e021667fdb 100644
--- a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/package-info.java
+++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Spring Data JPA specific converter infrastructure.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.envers.repository.support;
diff --git a/spring-data-jpa-distribution/pom.xml b/spring-data-jpa-distribution/pom.xml
index 38a234cb71..9afab6d3ee 100644
--- a/spring-data-jpa-distribution/pom.xml
+++ b/spring-data-jpa-distribution/pom.xml
@@ -14,7 +14,7 @@
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-jpa-parent</artifactId>
-		<version>3.5.0-SNAPSHOT</version>
+		<version>4.0.0-GH-3877-SNAPSHOT</version>
 		<relativePath>../pom.xml</relativePath>
 	</parent>
 
diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml
index 475a520c21..6ca6787c8e 100644
--- a/spring-data-jpa/pom.xml
+++ b/spring-data-jpa/pom.xml
@@ -7,7 +7,7 @@
 
 	<groupId>org.springframework.data</groupId>
 	<artifactId>spring-data-jpa</artifactId>
-	<version>3.5.0-SNAPSHOT</version>
+	<version>4.0.0-GH-3877-SNAPSHOT</version>
 
 	<name>Spring Data JPA</name>
 	<description>Spring Data module for JPA repositories.</description>
@@ -16,7 +16,7 @@
 	<parent>
 		<groupId>org.springframework.data</groupId>
 		<artifactId>spring-data-jpa-parent</artifactId>
-		<version>3.5.0-SNAPSHOT</version>
+		<version>4.0.0-GH-3877-SNAPSHOT</version>
 		<relativePath>../pom.xml</relativePath>
 	</parent>
 
@@ -73,12 +73,6 @@
 		<dependency>
 			<groupId>org.springframework</groupId>
 			<artifactId>spring-core</artifactId>
-			<exclusions>
-				<exclusion>
-					<groupId>commons-logging</groupId>
-					<artifactId>commons-logging</artifactId>
-				</exclusion>
-			</exclusions>
 		</dependency>
 
 		<dependency>
@@ -100,6 +94,25 @@
 			<scope>test</scope>
 		</dependency>
 
+		<dependency>
+			<groupId>org.springframework</groupId>
+			<artifactId>spring-core-test</artifactId>
+			<scope>test</scope>
+		</dependency>
+
+		<dependency>
+			<groupId>net.javacrumbs.json-unit</groupId>
+			<artifactId>json-unit-assertj</artifactId>
+			<version>4.1.0</version>
+			<scope>test</scope>
+		</dependency>
+
+		<dependency>
+			<groupId>com.fasterxml.jackson.core</groupId>
+			<artifactId>jackson-databind</artifactId>
+			<scope>test</scope>
+		</dependency>
+
 		<dependency>
 			<groupId>org.hsqldb</groupId>
 			<artifactId>hsqldb</artifactId>
@@ -245,6 +258,12 @@
 			<optional>true</optional>
 		</dependency>
 
+		<dependency>
+			<groupId>org.jboss.logging</groupId>
+			<artifactId>jboss-logging</artifactId>
+			<version>3.6.1.Final</version>
+		</dependency>
+
 	</dependencies>
 
 	<build>
@@ -296,7 +315,6 @@
 							</includes>
 							<excludes>
 								<exclude>**/*UnitTests.java</exclude>
-								<exclude>**/OpenJpa*</exclude>
 								<exclude>**/EclipseLink*</exclude>
 								<exclude>**/MySql*</exclude>
 								<exclude>**/Postgres*</exclude>
@@ -350,7 +368,7 @@
 				<groupId>org.apache.maven.plugins</groupId>
 				<artifactId>maven-compiler-plugin</artifactId>
 				<configuration>
-					<annotationProcessorPaths>
+					<annotationProcessorPaths combine.children="append">
 						<path>
 							<groupId>com.querydsl</groupId>
 							<artifactId>querydsl-apt</artifactId>
@@ -377,6 +395,11 @@
 							<artifactId>jakarta.persistence-api</artifactId>
 							<version>${jakarta-persistence-api}</version>
 						</path>
+						<path>
+							<groupId>org.jboss.logging</groupId>
+							<artifactId>jboss-logging</artifactId>
+							<version>3.6.1.Final</version>
+						</path>
 					</annotationProcessorPaths>
 				</configuration>
 			</plugin>
@@ -431,45 +454,4 @@
 		</plugins>
 	</build>
 
-	<profiles>
-		<profile>
-			<id>all-dbs</id>
-			<build>
-				<plugins>
-					<plugin>
-						<groupId>org.apache.maven.plugins</groupId>
-						<artifactId>maven-surefire-plugin</artifactId>
-						<executions>
-							<execution>
-								<id>mysql-test</id>
-								<phase>test</phase>
-								<goals>
-									<goal>test</goal>
-								</goals>
-								<configuration>
-									<includes>
-										<include>**/MySql*IntegrationTests.java</include>
-									</includes>
-								</configuration>
-							</execution>
-							<execution>
-								<id>postgres-test</id>
-								<phase>test</phase>
-								<goals>
-									<goal>test</goal>
-								</goals>
-								<configuration>
-									<includes>
-										<include>**/Postgres*IntegrationTests.java
-										</include>
-									</includes>
-								</configuration>
-							</execution>
-						</executions>
-					</plugin>
-				</plugins>
-			</build>
-		</profile>
-	</profiles>
-
 </project>
diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/AotRepositoryQueryMethodBenchmarks.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/AotRepositoryQueryMethodBenchmarks.java
new file mode 100644
index 0000000000..a8682d32c3
--- /dev/null
+++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/AotRepositoryQueryMethodBenchmarks.java
@@ -0,0 +1,260 @@
+/*
+ * 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.springframework.data.jpa.benchmark;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.Query;
+
+import java.util.List;
+import java.util.Properties;
+import java.util.Set;
+
+import org.hibernate.jpa.HibernatePersistenceProvider;
+import org.junit.platform.commons.annotation.Testable;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.TearDown;
+import org.openjdk.jmh.annotations.Timeout;
+import org.openjdk.jmh.annotations.Warmup;
+
+import org.springframework.aot.test.generate.TestGenerationContext;
+import org.springframework.core.test.tools.TestCompiler;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.benchmark.model.Person;
+import org.springframework.data.jpa.benchmark.model.Profile;
+import org.springframework.data.jpa.benchmark.repository.PersonRepository;
+import org.springframework.data.jpa.repository.aot.JpaRepositoryContributor;
+import org.springframework.data.jpa.repository.aot.TestJpaAotRepositoryContext;
+import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
+import org.springframework.data.projection.ProjectionFactory;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryComposition;
+import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport;
+import org.springframework.data.repository.query.ValueExpressionDelegate;
+import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
+import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
+import org.springframework.util.ObjectUtils;
+
+/**
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ */
+@Testable
+@Fork(1)
+@Warmup(time = 1, iterations = 3)
+@Measurement(time = 1, iterations = 3)
+@Timeout(time = 2)
+public class AotRepositoryQueryMethodBenchmarks {
+
+	private static final String PERSON_FIRSTNAME = "first";
+	private static final String COLUMN_PERSON_FIRSTNAME = "firstname";
+
+	@State(Scope.Benchmark)
+	public static class BenchmarkParameters {
+
+		public static Class<?> aot;
+		public static TestJpaAotRepositoryContext<PersonRepository> repositoryContext = new TestJpaAotRepositoryContext<>(
+				PersonRepository.class, null);
+
+		EntityManager entityManager;
+		RepositoryComposition.RepositoryFragments fragments;
+		PersonRepository repositoryProxy;
+
+		@Setup(Level.Iteration)
+		public void doSetup() {
+
+			createEntityManager();
+
+			if (!entityManager.getTransaction().isActive()) {
+
+				if (ObjectUtils.nullSafeEquals(
+						entityManager.createNativeQuery("SELECT COUNT(*) FROM person", Integer.class).getSingleResult(),
+						Integer.valueOf(0))) {
+
+					entityManager.getTransaction().begin();
+
+					Profile generalProfile = new Profile("general");
+					Profile sdUserProfile = new Profile("sd-user");
+
+					entityManager.persist(generalProfile);
+					entityManager.persist(sdUserProfile);
+
+					Person person = new Person(PERSON_FIRSTNAME, "last");
+					person.setProfiles(Set.of(generalProfile, sdUserProfile));
+					entityManager.persist(person);
+					entityManager.getTransaction().commit();
+				}
+			}
+
+			if (this.aot == null) {
+
+				TestGenerationContext generationContext = new TestGenerationContext(PersonRepository.class);
+
+				new JpaRepositoryContributor(repositoryContext, entityManager.getEntityManagerFactory())
+						.contribute(generationContext);
+
+				TestCompiler.forSystem().withCompilerOptions("-parameters").with(generationContext).compile(compiled -> {
+
+					try {
+						this.aot = compiled.getClassLoader().loadClass(PersonRepository.class.getName() + "Impl__Aot");
+					} catch (Exception e) {
+						throw new RuntimeException(e);
+					}
+				});
+			}
+
+			try {
+				RepositoryFactoryBeanSupport.FragmentCreationContext creationContext = getCreationContext(repositoryContext);
+				fragments = RepositoryComposition.RepositoryFragments
+						.just(aot.getConstructor(EntityManager.class, RepositoryFactoryBeanSupport.FragmentCreationContext.class)
+								.newInstance(entityManager, creationContext));
+
+				this.repositoryProxy = createRepository(fragments);
+			} catch (Exception e) {
+				throw new RuntimeException(e);
+			}
+		}
+
+		private RepositoryFactoryBeanSupport.FragmentCreationContext getCreationContext(
+				TestJpaAotRepositoryContext<?> repositoryContext) {
+
+			RepositoryFactoryBeanSupport.FragmentCreationContext creationContext = new RepositoryFactoryBeanSupport.FragmentCreationContext() {
+				@Override
+				public RepositoryMetadata getRepositoryMetadata() {
+					return repositoryContext.getRepositoryInformation();
+				}
+
+				@Override
+				public ValueExpressionDelegate getValueExpressionDelegate() {
+					return ValueExpressionDelegate.create();
+				}
+
+				@Override
+				public ProjectionFactory getProjectionFactory() {
+					return new SpelAwareProxyProjectionFactory();
+				}
+			};
+
+			return creationContext;
+		}
+
+		@TearDown(Level.Iteration)
+		public void doTearDown() {
+			entityManager.close();
+		}
+
+		private void createEntityManager() {
+
+			LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean();
+			factoryBean.setPersistenceUnitName("benchmark");
+			factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
+			factoryBean.setPersistenceProviderClass(HibernatePersistenceProvider.class);
+			factoryBean.setPersistenceXmlLocation("classpath*:META-INF/persistence-jmh.xml");
+			factoryBean.setMappingResources("classpath*:META-INF/orm-jmh.xml");
+
+			Properties properties = new Properties();
+			properties.put("jakarta.persistence.jdbc.url", "jdbc:h2:mem:test");
+			properties.put("hibernate.dialect", "org.hibernate.dialect.H2Dialect");
+			properties.put("hibernate.hbm2ddl.auto", "update");
+			properties.put("hibernate.xml_mapping_enabled", "false");
+
+			factoryBean.setJpaProperties(properties);
+			factoryBean.afterPropertiesSet();
+
+			EntityManagerFactory entityManagerFactory = factoryBean.getObject();
+			entityManager = entityManagerFactory.createEntityManager();
+		}
+
+		public PersonRepository createRepository(RepositoryComposition.RepositoryFragments fragments) {
+			JpaRepositoryFactory repositoryFactory = new JpaRepositoryFactory(entityManager);
+			return repositoryFactory.getRepository(PersonRepository.class, fragments);
+		}
+
+	}
+
+	protected PersonRepository doCreateRepository(EntityManager entityManager) {
+		JpaRepositoryFactory repositoryFactory = new JpaRepositoryFactory(entityManager);
+		return repositoryFactory.getRepository(PersonRepository.class);
+	}
+
+	@Benchmark
+	public PersonRepository repositoryBootstrap(BenchmarkParameters parameters) {
+		return parameters.createRepository(parameters.fragments);
+	}
+
+	@Benchmark
+	public List<Person> baselineEntityManagerHQLQuery(BenchmarkParameters parameters) {
+
+		Query query = parameters.entityManager
+				.createQuery("SELECT p FROM org.springframework.data.jpa.benchmark.model.Person p WHERE p.firstname = ?1");
+		query.setParameter(1, PERSON_FIRSTNAME);
+
+		return query.getResultList();
+	}
+
+	@Benchmark
+	public Long baselineEntityManagerCount(BenchmarkParameters parameters) {
+
+		Query query = parameters.entityManager.createQuery(
+				"SELECT COUNT(*) FROM org.springframework.data.jpa.benchmark.model.Person p WHERE p.firstname = ?1");
+		query.setParameter(1, PERSON_FIRSTNAME);
+
+		return (Long) query.getSingleResult();
+	}
+
+	@Benchmark
+	public List<Person> derivedFinderMethod(BenchmarkParameters parameters) {
+		return parameters.repositoryProxy.findAllByFirstname(PERSON_FIRSTNAME);
+	}
+
+	/*@Benchmark
+	public List<IPersonProjection> derivedFinderMethodWithInterfaceProjection(BenchmarkParameters parameters) {
+		return parameters.repositoryProxy.findAllAndProjectToInterfaceByFirstname(PERSON_FIRSTNAME);
+	} */
+
+	@Benchmark
+	public List<Person> stringBasedQuery(BenchmarkParameters parameters) {
+		return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME);
+	}
+
+	@Benchmark
+	public List<Person> stringBasedQueryDynamicSort(BenchmarkParameters parameters) {
+		return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME,
+				Sort.by(COLUMN_PERSON_FIRSTNAME));
+	}
+
+	@Benchmark
+	public List<Person> stringBasedNativeQuery(BenchmarkParameters parameters) {
+		return parameters.repositoryProxy.findAllWithNativeQueryByFirstname(PERSON_FIRSTNAME);
+	}
+
+	@Benchmark
+	public Long derivedCount(BenchmarkParameters parameters) {
+		return parameters.repositoryProxy.countByFirstname(PERSON_FIRSTNAME);
+	}
+
+	@Benchmark
+	public Long stringBasedCount(BenchmarkParameters parameters) {
+		return parameters.repositoryProxy.countWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME);
+	}
+}
diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryFinderBenchmarks.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryQueryMethodBenchmarks.java
similarity index 89%
rename from spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryFinderBenchmarks.java
rename to spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryQueryMethodBenchmarks.java
index 209dc55318..0f20652d65 100644
--- a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryFinderBenchmarks.java
+++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryQueryMethodBenchmarks.java
@@ -41,8 +41,8 @@
 import org.openjdk.jmh.annotations.Warmup;
 
 import org.springframework.data.domain.Sort;
-import org.springframework.data.jpa.benchmark.model.IPersonProjection;
 import org.springframework.data.jpa.benchmark.model.Person;
+import org.springframework.data.jpa.benchmark.model.PersonDto;
 import org.springframework.data.jpa.benchmark.model.Profile;
 import org.springframework.data.jpa.benchmark.repository.PersonRepository;
 import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
@@ -52,13 +52,14 @@
 
 /**
  * @author Christoph Strobl
+ * @author Mark Paluch
  */
 @Testable
 @Fork(1)
-@Warmup(time = 2, iterations = 3)
-@Measurement(time = 2)
+@Warmup(time = 1, iterations = 3)
+@Measurement(time = 1, iterations = 3)
 @Timeout(time = 2)
-public class RepositoryFinderBenchmarks {
+public class RepositoryQueryMethodBenchmarks {
 
 	private static final String PERSON_FIRSTNAME = "first";
 	private static final String COLUMN_PERSON_FIRSTNAME = "firstname";
@@ -125,10 +126,16 @@ private void createEntityManager() {
 			entityManager = entityManagerFactory.createEntityManager();
 		}
 
-		PersonRepository createRepository() {
+		public PersonRepository createRepository() {
 			JpaRepositoryFactory repositoryFactory = new JpaRepositoryFactory(entityManager);
 			return repositoryFactory.getRepository(PersonRepository.class);
 		}
+
+	}
+
+	protected PersonRepository doCreateRepository(EntityManager entityManager) {
+		JpaRepositoryFactory repositoryFactory = new JpaRepositoryFactory(entityManager);
+		return repositoryFactory.getRepository(PersonRepository.class);
 	}
 
 	@Benchmark
@@ -173,10 +180,10 @@ public List<Person> derivedFinderMethod(BenchmarkParameters parameters) {
 		return parameters.repositoryProxy.findAllByFirstname(PERSON_FIRSTNAME);
 	}
 
-	@Benchmark
+	/*@Benchmark
 	public List<IPersonProjection> derivedFinderMethodWithInterfaceProjection(BenchmarkParameters parameters) {
 		return parameters.repositoryProxy.findAllAndProjectToInterfaceByFirstname(PERSON_FIRSTNAME);
-	}
+	} */
 
 	@Benchmark
 	public List<Person> stringBasedQuery(BenchmarkParameters parameters) {
@@ -185,7 +192,14 @@ public List<Person> stringBasedQuery(BenchmarkParameters parameters) {
 
 	@Benchmark
 	public List<Person> stringBasedQueryDynamicSort(BenchmarkParameters parameters) {
-		return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME, Sort.by(COLUMN_PERSON_FIRSTNAME));
+		return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME,
+				Sort.by(COLUMN_PERSON_FIRSTNAME));
+	}
+
+	@Benchmark
+	public List<PersonDto> stringBasedQueryDynamicSortAndProjection(BenchmarkParameters parameters) {
+		return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME,
+				Sort.by(COLUMN_PERSON_FIRSTNAME), PersonDto.class);
 	}
 
 	@Benchmark
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaQueryUtilsIntegrationTests.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/PersonDto.java
similarity index 64%
rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaQueryUtilsIntegrationTests.java
rename to spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/PersonDto.java
index fd8f1cb634..6241e6a439 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaQueryUtilsIntegrationTests.java
+++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/PersonDto.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2013-2025 the original author or authors.
+ * Copyright 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.
@@ -13,14 +13,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.springframework.data.jpa.repository.query;
-
-import org.springframework.test.context.ContextConfiguration;
+package org.springframework.data.jpa.benchmark.model;
 
 /**
- * @author Oliver Gierke
+ * @author Mark Paluch
  */
-@ContextConfiguration("classpath:openjpa.xml")
-class OpenJpaQueryUtilsIntegrationTests extends QueryUtilsIntegrationTests {
-
+public record PersonDto(String firstname, String lastname) {
 }
diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/repository/PersonRepository.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/repository/PersonRepository.java
index 491ab736a8..81950ab3fa 100644
--- a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/repository/PersonRepository.java
+++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/repository/PersonRepository.java
@@ -38,6 +38,9 @@ public interface PersonRepository extends ListCrudRepository<Person, Integer> {
 	@Query("SELECT p FROM org.springframework.data.jpa.benchmark.model.Person p WHERE p.firstname = ?1")
 	List<Person> findAllWithAnnotatedQueryByFirstname(String firstname, Sort sort);
 
+	@Query("SELECT p FROM org.springframework.data.jpa.benchmark.model.Person p WHERE p.firstname = ?1")
+	<T> List<T> findAllWithAnnotatedQueryByFirstname(String firstname, Sort sort, Class<T> projection);
+
 	@Query(value = "SELECT * FROM person WHERE firstname = ?1", nativeQuery = true)
 	List<Person> findAllWithNativeQueryByFirstname(String firstname);
 
diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/HqlParserBenchmarks.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/HqlParserBenchmarks.java
index fd46a3f6c2..d1465ed1bc 100644
--- a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/HqlParserBenchmarks.java
+++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/HqlParserBenchmarks.java
@@ -27,6 +27,8 @@
 import org.openjdk.jmh.annotations.Warmup;
 
 import org.springframework.data.domain.Sort;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.query.ReturnedType;
 
 /**
  * @author Mark Paluch
@@ -44,6 +46,7 @@ public static class BenchmarkParameters {
 		DeclaredQuery query;
 		Sort sort = Sort.by("foo");
 		QueryEnhancer enhancer;
+		QueryEnhancer.QueryRewriteInformation rewriteInformation;
 
 		@Setup(Level.Iteration)
 		public void doSetup() {
@@ -55,14 +58,16 @@ OR TREAT(p AS SmallProject).name LIKE 'Persist%'
 					    OR p.description LIKE "cost overrun"
 					""";
 
-			query = DeclaredQuery.of(s, false);
-			enhancer = QueryEnhancerFactory.forQuery(query);
+			query = DeclaredQuery.jpqlQuery(s);
+			enhancer = QueryEnhancerFactory.forQuery(query).create(query);
+			rewriteInformation = new DefaultQueryRewriteInformation(sort,
+					ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()));
 		}
 	}
 
 	@Benchmark
 	public Object measure(BenchmarkParameters parameters) {
-		return parameters.enhancer.applySorting(parameters.sort);
+		return parameters.enhancer.rewrite(parameters.rewriteInformation);
 	}
 
 }
diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerBenchmarks.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerBenchmarks.java
index 845282e319..f4121c28ed 100644
--- a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerBenchmarks.java
+++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerBenchmarks.java
@@ -29,6 +29,8 @@
 import org.openjdk.jmh.annotations.Warmup;
 
 import org.springframework.data.domain.Sort;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.query.ReturnedType;
 
 /**
  * @author Mark Paluch
@@ -46,6 +48,7 @@ public static class BenchmarkParameters {
 		JSqlParserQueryEnhancer enhancer;
 		Sort sort = Sort.by("foo");
 		private byte[] serialized;
+		private QueryEnhancer.QueryRewriteInformation rewriteInformation;
 
 		@Setup(Level.Iteration)
 		public void doSetup() throws IOException {
@@ -56,13 +59,15 @@ public void doSetup() throws IOException {
 					select SOME_COLUMN from SOME_OTHER_TABLE where REPORTING_DATE = :REPORTING_DATE
 					union select SOME_COLUMN from SOME_OTHER_OTHER_TABLE""";
 
-			enhancer = new JSqlParserQueryEnhancer(DeclaredQuery.of(s, true));
+			enhancer = new JSqlParserQueryEnhancer(DeclaredQuery.nativeQuery(s));
+			rewriteInformation = new DefaultQueryRewriteInformation(sort,
+					ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()));
 		}
 	}
 
 	@Benchmark
 	public Object applySortWithParsing(BenchmarkParameters p) {
-		return p.enhancer.applySorting(p.sort);
+		return p.enhancer.rewrite(p.rewriteInformation);
 	}
 
 }
diff --git a/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Eql.g4 b/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Eql.g4
index 9a1136ddd1..6cfbe1db26 100644
--- a/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Eql.g4
+++ b/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Eql.g4
@@ -43,7 +43,7 @@ ql_statement
     ;
 
 select_statement
-    : select_clause from_clause (where_clause)? (groupby_clause)? (having_clause)? (orderby_clause)? (setOperator select_statement)*
+    : select_clause from_clause (where_clause)? (groupby_clause)? (having_clause)? (orderby_clause)? (set_fuction)?
     ;
 
 setOperator
@@ -52,6 +52,10 @@ setOperator
     | EXCEPT ALL?
     ;
 
+set_fuction
+    : setOperator select_statement
+    ;
+
 update_statement
     : update_clause (where_clause)?
     ;
@@ -211,6 +215,7 @@ constructor_item
     | scalar_expression
     | aggregate_expression
     | identification_variable
+    | literal
     ;
 
 aggregate_expression
@@ -309,6 +314,7 @@ scalar_expression
     | datetime_expression
     | boolean_expression
     | case_expression
+    | cast_function
     | entity_type_expression
     ;
 
@@ -455,6 +461,7 @@ string_expression
     | case_expression
     | function_invocation
     | '(' subquery ')'
+    | string_expression '||' string_expression
     ;
 
 datetime_expression
@@ -539,6 +546,9 @@ functions_returning_strings
     | TRIM '(' ((trim_specification)? (trim_character)? FROM)? string_expression ')'
     | LOWER '(' string_expression ')'
     | UPPER '(' string_expression ')'
+    | REPLACE '(' string_expression ',' string_expression ',' string_expression ')'
+    | LEFT '(' string_expression ',' arithmetic_expression ')'
+    | RIGHT '(' string_expression ',' arithmetic_expression ')'
     ;
 
 trim_specification
@@ -548,7 +558,7 @@ trim_specification
     ;
 
 cast_function
-    : CAST '(' single_valued_path_expression identification_variable ('(' numeric_literal (',' numeric_literal)* ')')? ')'
+    : CAST '(' single_valued_path_expression (identification_variable)? identification_variable ('(' numeric_literal (',' numeric_literal)* ')')? ')'
     ;
 
 function_invocation
@@ -614,6 +624,14 @@ nullif_expression
     : NULLIF '(' scalar_expression ',' scalar_expression ')'
     ;
 
+type_literal
+    : STRING
+    | INTEGER
+    | LONG
+    | FLOAT
+    | DOUBLE
+    ;
+
 /*******************
     Gaps in the spec.
  *******************/
@@ -626,6 +644,7 @@ trim_character
 identification_variable
     : IDENTIFICATION_VARIABLE
     | f=(COUNT
+    | AS
     | DATE
     | FROM
     | INNER
@@ -635,11 +654,13 @@ identification_variable
     | ORDER
     | OUTER
     | POWER
+    | RIGHT
     | FLOOR
     | SIGN
     | TIME
     | TYPE
     | VALUE)
+    | type_literal
     ;
 
 constructor_name
@@ -648,6 +669,7 @@ constructor_name
 
 literal
     : STRINGLITERAL
+    | JAVASTRINGLITERAL
     | INTLITERAL
     | FLOATLITERAL
     | LONGLITERAL
@@ -821,6 +843,8 @@ reserved_word
        |ORDER
        |OUTER
        |POWER
+       |REPLACE
+       |RIGHT
        |ROUND
        |SELECT
        |SET
@@ -903,6 +927,7 @@ DATETIME                    : D A T E T I M E ;
 DELETE                      : D E L E T E;
 DESC                        : D E S C;
 DISTINCT                    : D I S T I N C T;
+DOUBLE                      : D O U B L E;
 END                         : E N D;
 ELSE                        : E L S E;
 EMPTY                       : E M P T Y;
@@ -915,6 +940,7 @@ EXTRACT                     : E X T R A C T;
 FALSE                       : F A L S E;
 FETCH                       : F E T C H;
 FIRST                       : F I R S T;
+FLOAT                       : F L O A T;
 FLOOR                       : F L O O R;
 FROM                        : F R O M;
 FUNCTION                    : F U N C T I O N;
@@ -923,6 +949,7 @@ HAVING                      : H A V I N G;
 IN                          : I N;
 INDEX                       : I N D E X;
 INNER                       : I N N E R;
+INTEGER                     : I N T E G E R;
 INTERSECT                   : I N T E R S E C T;
 IS                          : I S;
 JOIN                        : J O I N;
@@ -935,6 +962,7 @@ LIKE                        : L I K E;
 LN                          : L N;
 LOCAL                       : L O C A L;
 LOCATE                      : L O C A T E;
+LONG                        : L O N G;
 LOWER                       : L O W E R;
 MAX                         : M A X;
 MEMBER                      : M E M B E R;
@@ -953,6 +981,8 @@ ORDER                       : O R D E R;
 OUTER                       : O U T E R;
 POWER                       : P O W E R;
 REGEXP                      : R E G E X P;
+REPLACE                     : R E P L A C E;
+RIGHT                       : R I G H T;
 ROUND                       : R O U N D;
 SELECT                      : S E L E C T;
 SET                         : S E T;
@@ -960,6 +990,7 @@ SIGN                        : S I G N;
 SIZE                        : S I Z E;
 SOME                        : S O M E;
 SQRT                        : S Q R T;
+STRING                      : S T R I N G;
 SUBSTRING                   : S U B S T R I N G;
 SUM                         : S U M;
 THEN                        : T H E N;
@@ -979,9 +1010,9 @@ WHERE                       : W H E R E;
 EQUAL                       : '=' ;
 NOT_EQUAL                   : '<>' | '!=' ;
 
-
 CHARACTER                   : '\'' (~ ('\'' | '\\')) '\'' ;
 IDENTIFICATION_VARIABLE     : ('a' .. 'z' | 'A' .. 'Z' | '\u0080' .. '\ufffe' | '$' | '_') ('a' .. 'z' | 'A' .. 'Z' | '\u0080' .. '\ufffe' | '0' .. '9' | '$' | '_')* ;
+JAVASTRINGLITERAL           : '"' ( ('\\' [btnfr"']) | ~('"'))* '"';
 STRINGLITERAL               : '\'' (~ ('\'' | '\\')|'\\')* '\'' ;
 FLOATLITERAL                : ('0' .. '9')* '.' ('0' .. '9')+ (E ('0' .. '9')+)* (F|D)?;
 INTLITERAL                  : ('0' .. '9')+ ;
diff --git a/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Hql.g4 b/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Hql.g4
index da0518d08c..d18a924a51 100644
--- a/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Hql.g4
+++ b/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Hql.g4
@@ -89,8 +89,8 @@ orderedQuery
     ;
 
 query
-    : selectClause fromClause? whereClause? (groupByClause havingClause?)? # SelectQuery
-    | fromClause whereClause? (groupByClause havingClause?)? selectClause? # FromQuery
+    : selectClause fromClause? whereClause? groupByClause? havingClause? # SelectQuery
+    | fromClause whereClause? groupByClause? havingClause? selectClause? # FromQuery
     ;
 
 queryOrder
diff --git a/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Jpql.g4 b/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Jpql.g4
index 98a0df214f..d477ea2467 100644
--- a/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Jpql.g4
+++ b/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Jpql.g4
@@ -43,7 +43,17 @@ ql_statement
     ;
 
 select_statement
-    : select_clause from_clause (where_clause)? (groupby_clause)? (having_clause)? (orderby_clause)?
+    : select_clause from_clause (where_clause)? (groupby_clause)? (having_clause)? (orderby_clause)? (set_fuction)?
+    ;
+
+setOperator
+    : UNION ALL?
+    | INTERSECT ALL?
+    | EXCEPT ALL?
+    ;
+
+set_fuction
+    : setOperator select_statement
     ;
 
 update_statement
@@ -77,7 +87,7 @@ join
     ;
 
 fetch_join
-    : join_spec FETCH join_association_path_expression
+    : join_spec FETCH join_association_path_expression AS? identification_variable? join_condition?
     ;
 
 join_spec
@@ -297,6 +307,7 @@ scalar_expression
     | datetime_expression
     | boolean_expression
     | case_expression
+    | cast_function
     | entity_type_expression
     ;
 
@@ -427,6 +438,7 @@ arithmetic_primary
     | functions_returning_numerics
     | aggregate_expression
     | case_expression
+    | cast_function
     | function_invocation
     | '(' subquery ')'
     ;
@@ -440,6 +452,7 @@ string_expression
     | case_expression
     | function_invocation
     | '(' subquery ')'
+    | string_expression '||' string_expression
     ;
 
 datetime_expression
@@ -524,6 +537,9 @@ functions_returning_strings
     | TRIM '(' ((trim_specification)? (trim_character)? FROM)? string_expression ')'
     | LOWER '(' string_expression ')'
     | UPPER '(' string_expression ')'
+    | REPLACE '(' string_expression ',' string_expression ',' string_expression ')'
+    | LEFT '(' string_expression ',' arithmetic_expression ')'
+    | RIGHT '(' string_expression ',' arithmetic_expression ')'
     ;
 
 trim_specification
@@ -532,6 +548,9 @@ trim_specification
     | BOTH
     ;
 
+cast_function
+    : CAST '(' single_valued_path_expression (identification_variable)? identification_variable ('(' numeric_literal (',' numeric_literal)* ')')? ')'
+    ;
 
 function_invocation
     : FUNCTION '(' function_name (',' function_arg)* ')'
@@ -596,6 +615,7 @@ nullif_expression
     : NULLIF '(' scalar_expression ',' scalar_expression ')'
     ;
 
+
 /*******************
     Gaps in the spec.
  *******************/
@@ -608,6 +628,7 @@ trim_character
 identification_variable
     : IDENTIFICATION_VARIABLE
     | f=(COUNT
+    | AS
     | DATE
     | FROM
     | INNER
@@ -617,11 +638,13 @@ identification_variable
     | ORDER
     | OUTER
     | POWER
+    | RIGHT
     | FLOOR
     | SIGN
     | TIME
     | TYPE
     | VALUE)
+    | type_literal
     ;
 
 constructor_name
@@ -649,6 +672,9 @@ pattern_value
 
 date_time_timestamp_literal
     : STRINGLITERAL
+    | DATELITERAL
+    | TIMELITERAL
+    | TIMESTAMPLITERAL
     ;
 
 entity_type_literal
@@ -666,6 +692,14 @@ numeric_literal
     | LONGLITERAL
     ;
 
+type_literal
+    : STRING
+    | INTEGER
+    | LONG
+    | FLOAT
+    | DOUBLE
+    ;
+
 boolean_literal
     : TRUE
     | FALSE
@@ -801,6 +835,8 @@ reserved_word
        |ORDER
        |OUTER
        |POWER
+       |REPLACE
+       |RIGHT
        |ROUND
        |SELECT
        |SET
@@ -870,6 +906,7 @@ BETWEEN                     : B E T W E E N;
 BOTH                        : B O T H;
 BY                          : B Y;
 CASE                        : C A S E;
+CAST                        : C A S T;
 CEILING                     : C E I L I N G;
 COALESCE                    : C O A L E S C E;
 CONCAT                      : C O N C A T;
@@ -882,17 +919,20 @@ DATETIME                    : D A T E T I M E ;
 DELETE                      : D E L E T E;
 DESC                        : D E S C;
 DISTINCT                    : D I S T I N C T;
+DOUBLE                      : D O U B L E;
 END                         : E N D;
 ELSE                        : E L S E;
 EMPTY                       : E M P T Y;
 ENTRY                       : E N T R Y;
 ESCAPE                      : E S C A P E;
+EXCEPT                      : E X C E P T;
 EXISTS                      : E X I S T S;
 EXP                         : E X P;
 EXTRACT                     : E X T R A C T;
 FALSE                       : F A L S E;
 FETCH                       : F E T C H;
 FIRST                       : F I R S T;
+FLOAT                       : F L O A T;
 FLOOR                       : F L O O R;
 FROM                        : F R O M;
 FUNCTION                    : F U N C T I O N;
@@ -901,6 +941,8 @@ HAVING                      : H A V I N G;
 IN                          : I N;
 INDEX                       : I N D E X;
 INNER                       : I N N E R;
+INTEGER                     : I N T E G E R;
+INTERSECT                   : I N T E R S E C T;
 IS                          : I S;
 JOIN                        : J O I N;
 KEY                         : K E Y;
@@ -912,6 +954,7 @@ LIKE                        : L I K E;
 LN                          : L N;
 LOCAL                       : L O C A L;
 LOCATE                      : L O C A T E;
+LONG                        : L O N G;
 LOWER                       : L O W E R;
 MAX                         : M A X;
 MEMBER                      : M E M B E R;
@@ -929,6 +972,9 @@ OR                          : O R;
 ORDER                       : O R D E R;
 OUTER                       : O U T E R;
 POWER                       : P O W E R;
+REGEXP                      : R E G E X P;
+REPLACE                     : R E P L A C E;
+RIGHT                       : R I G H T;
 ROUND                       : R O U N D;
 SELECT                      : S E L E C T;
 SET                         : S E T;
@@ -936,6 +982,7 @@ SIGN                        : S I G N;
 SIZE                        : S I Z E;
 SOME                        : S O M E;
 SQRT                        : S Q R T;
+STRING                      : S T R I N G;
 SUBSTRING                   : S U B S T R I N G;
 SUM                         : S U M;
 THEN                        : T H E N;
@@ -945,6 +992,7 @@ TREAT                       : T R E A T;
 TRIM                        : T R I M;
 TRUE                        : T R U E;
 TYPE                        : T Y P E;
+UNION                       : U N I O N;
 UPDATE                      : U P D A T E;
 UPPER                       : U P P E R;
 VALUE                       : V A L U E;
@@ -956,8 +1004,11 @@ NOT_EQUAL                   : '<>' | '!=' ;
 
 CHARACTER                   : '\'' (~ ('\'' | '\\')) '\'' ;
 IDENTIFICATION_VARIABLE     : ('a' .. 'z' | 'A' .. 'Z' | '\u0080' .. '\ufffe' | '$' | '_') ('a' .. 'z' | 'A' .. 'Z' | '\u0080' .. '\ufffe' | '0' .. '9' | '$' | '_')* ;
-STRINGLITERAL               : '\'' (~ ('\'' | '\\'))* '\'' ;
+STRINGLITERAL               : '\'' (~ ('\'' | '\\')|'\\')* '\'' ;
 JAVASTRINGLITERAL           : '"' ( ('\\' [btnfr"']) | ~('"'))* '"';
 FLOATLITERAL                : ('0' .. '9')* '.' ('0' .. '9')+ (E ('0' .. '9')+)* (F|D)?;
 INTLITERAL                  : ('0' .. '9')+ ;
-LONGLITERAL                  : ('0' .. '9')+L ;
+LONGLITERAL                 : ('0' .. '9')+ L;
+DATELITERAL                 : '{' D STRINGLITERAL '}';
+TIMELITERAL                 : '{' T STRINGLITERAL '}';
+TIMESTAMPLITERAL            : '{' T S STRINGLITERAL '}';
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java
index 6b2314a2d0..69baedf0dd 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java
@@ -34,6 +34,8 @@
 import java.util.Set;
 
 import org.springframework.dao.InvalidDataAccessApiUsageException;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.domain.Example;
 import org.springframework.data.domain.ExampleMatcher;
 import org.springframework.data.domain.ExampleMatcher.MatchMode;
@@ -41,7 +43,7 @@
 import org.springframework.data.jpa.repository.query.EscapeCharacter;
 import org.springframework.data.support.ExampleMatcherAccessor;
 import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper;
-import org.springframework.lang.Nullable;
+import org.springframework.lang.Contract;
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
 import org.springframework.util.ObjectUtils;
@@ -81,8 +83,7 @@ public class QueryByExamplePredicateBuilder {
 	 * @param example must not be {@literal null}.
 	 * @return {@literal null} indicates no {@link Predicate}.
 	 */
-	@Nullable
-	public static <T> Predicate getPredicate(Root<T> root, CriteriaBuilder cb, Example<T> example) {
+	public static <T> @Nullable Predicate getPredicate(Root<T> root, CriteriaBuilder cb, Example<T> example) {
 		return getPredicate(root, cb, example, EscapeCharacter.DEFAULT);
 	}
 
@@ -95,8 +96,7 @@ public static <T> Predicate getPredicate(Root<T> root, CriteriaBuilder cb, Examp
 	 * @param escapeCharacter Must not be {@literal null}.
 	 * @return {@literal null} indicates no constraints
 	 */
-	@Nullable
-	public static <T> Predicate getPredicate(Root<T> root, CriteriaBuilder cb, Example<T> example,
+	public static <T> @Nullable Predicate getPredicate(Root<T> root, CriteriaBuilder cb, Example<T> example,
 			EscapeCharacter escapeCharacter) {
 
 		Assert.notNull(root, "Root must not be null");
@@ -243,7 +243,6 @@ private static class PathNode {
 
 		String name;
 		@Nullable PathNode parent;
-		List<PathNode> siblings = new ArrayList<>();
 		@Nullable Object value;
 
 		PathNode(String edge, @Nullable PathNode parent, @Nullable Object value) {
@@ -255,9 +254,7 @@ private static class PathNode {
 
 		PathNode add(String attribute, @Nullable Object value) {
 
-			PathNode node = new PathNode(attribute, this, value);
-			siblings.add(node);
-			return node;
+			return new PathNode(attribute, this, value);
 		}
 
 		boolean spansCycle() {
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/package-info.java
index 8b3213871e..1090103cb7 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Spring Data JPA specific converter infrastructure.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.convert;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConverters.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConverters.java
index 87aeb9353e..12f29500eb 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConverters.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConverters.java
@@ -27,6 +27,9 @@
 import java.util.Date;
 
 import org.springframework.data.convert.Jsr310Converters.DateToLocalDateConverter;
+
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.convert.Jsr310Converters.DateToLocalDateTimeConverter;
 import org.springframework.data.convert.Jsr310Converters.DateToLocalTimeConverter;
 import org.springframework.data.convert.Jsr310Converters.LocalDateTimeToDateConverter;
@@ -36,8 +39,6 @@
 import org.springframework.data.convert.Jsr310Converters.ZoneIdToStringConverter;
 import org.springframework.data.convert.ReadingConverter;
 import org.springframework.data.convert.WritingConverter;
-import org.springframework.lang.NonNull;
-import org.springframework.lang.Nullable;
 import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
 
 /**
@@ -53,81 +54,71 @@
 public class Jsr310JpaConverters {
 
 	@Converter(autoApply = true)
-	public static class LocalDateConverter implements AttributeConverter<LocalDate, Date> {
+	public static class LocalDateConverter implements AttributeConverter<@Nullable LocalDate, @Nullable Date> {
 
-		@Nullable
 		@Override
-		public Date convertToDatabaseColumn(LocalDate date) {
+		public @Nullable Date convertToDatabaseColumn(@Nullable LocalDate date) {
 			return date == null ? null : LocalDateToDateConverter.INSTANCE.convert(date);
 		}
 
-		@Nullable
 		@Override
-		public LocalDate convertToEntityAttribute(Date date) {
+		public @Nullable LocalDate convertToEntityAttribute(@Nullable Date date) {
 			return date == null ? null : DateToLocalDateConverter.INSTANCE.convert(date);
 		}
 	}
 
 	@Converter(autoApply = true)
-	public static class LocalTimeConverter implements AttributeConverter<LocalTime, Date> {
+	public static class LocalTimeConverter implements AttributeConverter<@Nullable LocalTime, @Nullable Date> {
 
-		@Nullable
 		@Override
-		public Date convertToDatabaseColumn(LocalTime time) {
+		public @Nullable Date convertToDatabaseColumn(@Nullable LocalTime time) {
 			return time == null ? null : LocalTimeToDateConverter.INSTANCE.convert(time);
 		}
 
-		@Nullable
 		@Override
-		public LocalTime convertToEntityAttribute(Date date) {
+		public @Nullable LocalTime convertToEntityAttribute(@Nullable Date date) {
 			return date == null ? null : DateToLocalTimeConverter.INSTANCE.convert(date);
 		}
 	}
 
 	@Converter(autoApply = true)
-	public static class LocalDateTimeConverter implements AttributeConverter<LocalDateTime, Date> {
+	public static class LocalDateTimeConverter implements AttributeConverter<@Nullable LocalDateTime, @Nullable Date> {
 
-		@Nullable
 		@Override
-		public Date convertToDatabaseColumn(LocalDateTime date) {
+		public @Nullable Date convertToDatabaseColumn(@Nullable LocalDateTime date) {
 			return date == null ? null : LocalDateTimeToDateConverter.INSTANCE.convert(date);
 		}
 
-		@Nullable
 		@Override
-		public LocalDateTime convertToEntityAttribute(Date date) {
+		public @Nullable LocalDateTime convertToEntityAttribute(@Nullable Date date) {
 			return date == null ? null : DateToLocalDateTimeConverter.INSTANCE.convert(date);
 		}
 	}
 
 	@Converter(autoApply = true)
-	public static class InstantConverter implements AttributeConverter<Instant, Timestamp> {
+	public static class InstantConverter implements AttributeConverter<@Nullable Instant, @Nullable Timestamp> {
 
-		@Nullable
 		@Override
-		public Timestamp convertToDatabaseColumn(Instant instant) {
+		public @Nullable Timestamp convertToDatabaseColumn(@Nullable Instant instant) {
 			return instant == null ? null : InstantToTimestampConverter.INSTANCE.convert(instant);
 		}
 
-		@Nullable
 		@Override
-		public Instant convertToEntityAttribute(Timestamp timestamp) {
+		public @Nullable Instant convertToEntityAttribute(@Nullable Timestamp timestamp) {
 			return timestamp == null ? null : TimestampToInstantConverter.INSTANCE.convert(timestamp);
 		}
 	}
 
 	@Converter(autoApply = true)
-	public static class ZoneIdConverter implements AttributeConverter<ZoneId, String> {
+	public static class ZoneIdConverter implements AttributeConverter<@Nullable ZoneId, @Nullable String> {
 
-		@Nullable
 		@Override
-		public String convertToDatabaseColumn(ZoneId zoneId) {
+		public @Nullable String convertToDatabaseColumn(@Nullable ZoneId zoneId) {
 			return zoneId == null ? null : ZoneIdToStringConverter.INSTANCE.convert(zoneId);
 		}
 
-		@Nullable
 		@Override
-		public ZoneId convertToEntityAttribute(String zoneId) {
+		public @Nullable ZoneId convertToEntityAttribute(@Nullable String zoneId) {
 			return zoneId == null ? null : StringToZoneIdConverter.INSTANCE.convert(zoneId);
 		}
 	}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/package-info.java
index 0c00cdf218..716d2fe999 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Spring Data JPA specific JSR-310 converters.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.convert.threeten;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractAuditable.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractAuditable.java
index c2653a2e89..0b394d0472 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractAuditable.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractAuditable.java
@@ -17,17 +17,17 @@
 
 import jakarta.persistence.ManyToOne;
 import jakarta.persistence.MappedSuperclass;
-import jakarta.persistence.Temporal;
-import jakarta.persistence.TemporalType;
 
 import java.io.Serializable;
+import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneId;
-import java.util.Date;
 import java.util.Optional;
 
+import org.jspecify.annotations.NullUnmarked;
 import org.springframework.data.domain.Auditable;
-import org.springframework.lang.Nullable;
+
+import org.jspecify.annotations.Nullable;
 
 /**
  * Abstract base class for auditable entities. Stores the audition values in persistent fields.
@@ -39,20 +39,19 @@
  * @param <PK> the type of the auditing type's identifier.
  */
 @MappedSuperclass
+@SuppressWarnings("NullAway") // querydsl does not work with jspecify -> 'Did not find type @org.jspecify.annotations.Nullable...'
 public abstract class AbstractAuditable<U, PK extends Serializable> extends AbstractPersistable<PK>
 		implements Auditable<U, PK, LocalDateTime> {
 
 	@ManyToOne //
-	private @Nullable U createdBy;
+	private  U createdBy;
 
-	@Temporal(TemporalType.TIMESTAMP) //
-	private @Nullable Date createdDate;
+	private  Instant createdDate;
 
 	@ManyToOne //
-	private @Nullable U lastModifiedBy;
+	private U lastModifiedBy;
 
-	@Temporal(TemporalType.TIMESTAMP) //
-	private @Nullable Date lastModifiedDate;
+	private Instant lastModifiedDate;
 
 	@Override
 	public Optional<U> getCreatedBy() {
@@ -60,19 +59,19 @@ public Optional<U> getCreatedBy() {
 	}
 
 	@Override
-	public void setCreatedBy(U createdBy) {
+	public void setCreatedBy(@Nullable U createdBy) {
 		this.createdBy = createdBy;
 	}
 
 	@Override
 	public Optional<LocalDateTime> getCreatedDate() {
 		return null == createdDate ? Optional.empty()
-				: Optional.of(LocalDateTime.ofInstant(createdDate.toInstant(), ZoneId.systemDefault()));
+				: Optional.of(LocalDateTime.ofInstant(createdDate, ZoneId.systemDefault()));
 	}
 
 	@Override
 	public void setCreatedDate(LocalDateTime createdDate) {
-		this.createdDate = Date.from(createdDate.atZone(ZoneId.systemDefault()).toInstant());
+		this.createdDate = createdDate.atZone(ZoneId.systemDefault()).toInstant();
 	}
 
 	@Override
@@ -81,18 +80,18 @@ public Optional<U> getLastModifiedBy() {
 	}
 
 	@Override
-	public void setLastModifiedBy(U lastModifiedBy) {
+	public void setLastModifiedBy(@Nullable U lastModifiedBy) {
 		this.lastModifiedBy = lastModifiedBy;
 	}
 
 	@Override
 	public Optional<LocalDateTime> getLastModifiedDate() {
 		return null == lastModifiedDate ? Optional.empty()
-				: Optional.of(LocalDateTime.ofInstant(lastModifiedDate.toInstant(), ZoneId.systemDefault()));
+				: Optional.of(LocalDateTime.ofInstant(lastModifiedDate, ZoneId.systemDefault()));
 	}
 
 	@Override
 	public void setLastModifiedDate(LocalDateTime lastModifiedDate) {
-		this.lastModifiedDate = Date.from(lastModifiedDate.atZone(ZoneId.systemDefault()).toInstant());
+		this.lastModifiedDate = lastModifiedDate.atZone(ZoneId.systemDefault()).toInstant();
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractPersistable.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractPersistable.java
index 989eac2cff..19153d70c5 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractPersistable.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractPersistable.java
@@ -18,13 +18,14 @@
 import java.io.Serializable;
 
 import jakarta.persistence.GeneratedValue;
+
+import org.jspecify.annotations.Nullable;
 import jakarta.persistence.Id;
 import jakarta.persistence.MappedSuperclass;
 import jakarta.persistence.Transient;
 
 import org.springframework.data.domain.Persistable;
 import org.springframework.data.util.ProxyUtils;
-import org.springframework.lang.Nullable;
 
 /**
  * Abstract base class for entities. Allows parameterization of id type, chooses auto-generation and implements
@@ -38,11 +39,12 @@
  * @param <PK> the type of the identifier.
  */
 @MappedSuperclass
+@SuppressWarnings("NullAway") // querydsl does not work with jspecify -> 'Did not find type @org.jspecify.annotations.Nullable...'
 public abstract class AbstractPersistable<PK extends Serializable> implements Persistable<PK> {
 
-	@Id @GeneratedValue private @Nullable PK id;
-
 	@Nullable
+	@Id @GeneratedValue private PK id;
+
 	@Override
 	public PK getId() {
 		return id;
@@ -74,7 +76,7 @@ public String toString() {
 	}
 
 	@Override
-	public boolean equals(Object obj) {
+	public boolean equals(@Nullable Object obj) {
 
 		if (null == obj) {
 			return false;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/DeleteSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/DeleteSpecification.java
new file mode 100644
index 0000000000..4c7deb638d
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/DeleteSpecification.java
@@ -0,0 +1,236 @@
+/*
+ * Copyright 2024 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.springframework.data.jpa.domain;
+
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaDelete;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.stream.StreamSupport;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.lang.CheckReturnValue;
+import org.springframework.lang.Contract;
+import org.springframework.util.Assert;
+
+/**
+ * Specification in the sense of Domain Driven Design to handle Criteria Deletes.
+ * <p>
+ * Specifications can be composed into higher order functions from other specifications using
+ * {@link #and(DeleteSpecification)}, {@link #or(DeleteSpecification)} or factory methods such as
+ * {@link #allOf(Iterable)}. Composition considers whether one or more specifications contribute to the overall
+ * predicate by returning a {@link Predicate} or {@literal null}. Specifications returning {@literal null} are
+ * considered to not contribute to the overall predicate and their result is not considered in the final predicate.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+@FunctionalInterface
+public interface DeleteSpecification<T> extends Serializable {
+
+	/**
+	 * Simple static factory method to create a specification deleting all objects.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal DeleteSpecification} operates on.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> DeleteSpecification<T> unrestricted() {
+		return (root, query, builder) -> null;
+	}
+
+	/**
+	 * Simple static factory method to add some syntactic sugar around a {@literal DeleteSpecification}.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal DeleteSpecification} operates on.
+	 * @param spec must not be {@literal null}.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> DeleteSpecification<T> where(DeleteSpecification<T> spec) {
+
+		Assert.notNull(spec, "DeleteSpecification must not be null");
+
+		return spec;
+	}
+
+	/**
+	 * Simple static factory method to add some syntactic sugar translating {@link PredicateSpecification} to
+	 * {@link DeleteSpecification}.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal DeleteSpecification} operates on.
+	 * @param spec the {@link PredicateSpecification} to wrap.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> DeleteSpecification<T> where(PredicateSpecification<T> spec) {
+
+		Assert.notNull(spec, "PredicateSpecification must not be null");
+
+		return where((root, delete, criteriaBuilder) -> spec.toPredicate(root, criteriaBuilder));
+	}
+
+	/**
+	 * ANDs the given {@link DeleteSpecification} to the current one.
+	 *
+	 * @param other the other {@link DeleteSpecification}.
+	 * @return the conjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default DeleteSpecification<T> and(DeleteSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, other, CriteriaBuilder::and);
+	}
+
+	/**
+	 * ANDs the given {@link DeleteSpecification} to the current one.
+	 *
+	 * @param other the other {@link PredicateSpecification}.
+	 * @return the conjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default DeleteSpecification<T> and(PredicateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, where(other), CriteriaBuilder::and);
+	}
+
+	/**
+	 * ORs the given specification to the current one.
+	 *
+	 * @param other the other {@link DeleteSpecification}.
+	 * @return the disjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default DeleteSpecification<T> or(DeleteSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, other, CriteriaBuilder::or);
+	}
+
+	/**
+	 * ORs the given specification to the current one.
+	 *
+	 * @param other the other {@link PredicateSpecification}.
+	 * @return the disjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default DeleteSpecification<T> or(PredicateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, where(other), CriteriaBuilder::or);
+	}
+
+	/**
+	 * Negates the given {@link DeleteSpecification}.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal DeleteSpecification} operates on.
+	 * @param spec can be {@literal null}.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	@Contract("_ -> new")
+	static <T> DeleteSpecification<T> not(DeleteSpecification<T> spec) {
+
+		Assert.notNull(spec, "Specification must not be null");
+
+		return (root, delete, builder) -> {
+
+			Predicate predicate = spec.toPredicate(root, delete, builder);
+			return predicate != null ? builder.not(predicate) : builder.disjunction();
+		};
+	}
+
+	/**
+	 * Applies an AND operation to all the given {@link DeleteSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link DeleteSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link DeleteSpecification}s to compose.
+	 * @return the conjunction of the specifications.
+	 * @see #and(DeleteSpecification)
+	 * @see #allOf(Iterable)
+	 */
+	@SafeVarargs
+	static <T> DeleteSpecification<T> allOf(DeleteSpecification<T>... specifications) {
+		return allOf(Arrays.asList(specifications));
+	}
+
+	/**
+	 * Applies an AND operation to all the given {@link DeleteSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link DeleteSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link DeleteSpecification}s to compose.
+	 * @return the conjunction of the specifications.
+	 * @see #and(DeleteSpecification)
+	 * @see #allOf(DeleteSpecification[])
+	 */
+	static <T> DeleteSpecification<T> allOf(Iterable<DeleteSpecification<T>> specifications) {
+
+		return StreamSupport.stream(specifications.spliterator(), false) //
+				.reduce(DeleteSpecification.unrestricted(), DeleteSpecification::and);
+	}
+
+	/**
+	 * Applies an OR operation to all the given {@link DeleteSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link DeleteSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link DeleteSpecification}s to compose.
+	 * @return the disjunction of the specifications.
+	 * @see #or(DeleteSpecification)
+	 * @see #anyOf(Iterable)
+	 */
+	@SafeVarargs
+	static <T> DeleteSpecification<T> anyOf(DeleteSpecification<T>... specifications) {
+		return anyOf(Arrays.asList(specifications));
+	}
+
+	/**
+	 * Applies an OR operation to all the given {@link DeleteSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link DeleteSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link DeleteSpecification}s to compose.
+	 * @return the disjunction of the specifications.
+	 * @see #or(DeleteSpecification)
+	 * @see #anyOf(Iterable)
+	 */
+	static <T> DeleteSpecification<T> anyOf(Iterable<DeleteSpecification<T>> specifications) {
+
+		return StreamSupport.stream(specifications.spliterator(), false) //
+				.reduce(DeleteSpecification.unrestricted(), DeleteSpecification::or);
+	}
+
+	/**
+	 * Creates a WHERE clause for a query of the referenced entity in form of a {@link Predicate} for the given
+	 * {@link Root} and {@link CriteriaDelete}.
+	 *
+	 * @param root must not be {@literal null}.
+	 * @param delete the delete criteria.
+	 * @param criteriaBuilder must not be {@literal null}.
+	 * @return a {@link Predicate}, may be {@literal null}.
+	 */
+	@Nullable
+	Predicate toPredicate(Root<T> root, CriteriaDelete<T> delete, CriteriaBuilder criteriaBuilder);
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java
index 771b5361a6..2f55c0bf03 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java
@@ -18,10 +18,6 @@
 import jakarta.persistence.metamodel.Attribute;
 import jakarta.persistence.metamodel.PluralAttribute;
 
-import org.springframework.data.domain.Sort;
-import org.springframework.lang.Nullable;
-import org.springframework.util.Assert;
-
 import java.io.Serial;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -29,8 +25,18 @@
 import java.util.Collections;
 import java.util.List;
 
+import org.springframework.data.domain.Sort;
+
+import org.jspecify.annotations.Nullable;
+import org.springframework.lang.CheckReturnValue;
+import org.springframework.lang.Contract;
+import org.springframework.util.Assert;
+
 /**
- * Sort option for queries that wraps JPA meta-model {@link Attribute}s for sorting.
+ * Sort option for queries that wraps JPA metamodel {@link Attribute}s for sorting.
+ * <p>
+ * {@link JpaSort#unsafe} accepts unsafe sort expressions, i. e. the String provided is not necessarily a property but
+ * can be an arbitrary expression piped into the query execution.
  *
  * @author Thomas Darimont
  * @author Oliver Gierke
@@ -44,7 +50,7 @@ public class JpaSort extends Sort {
 	@Serial private static final long serialVersionUID = 1L;
 
 	private JpaSort(Direction direction, List<Path<?, ?>> paths) {
-		this(Collections.<Order>emptyList(), direction, paths);
+		this(Collections.<Order> emptyList(), direction, paths);
 	}
 
 	private JpaSort(List<Order> orders, @Nullable Direction direction, List<Path<?, ?>> paths) {
@@ -76,7 +82,7 @@ public static JpaSort of(JpaSort.Path<?, ?>... paths) {
 	/**
 	 * Creates a new {@link JpaSort} for the given direction and attributes.
 	 *
-	 * @param direction  the sorting direction.
+	 * @param direction the sorting direction.
 	 * @param attributes must not be {@literal null} or empty.
 	 */
 	public static JpaSort of(Direction direction, Attribute<?, ?>... attributes) {
@@ -87,7 +93,7 @@ public static JpaSort of(Direction direction, Attribute<?, ?>... attributes) {
 	 * Creates a new {@link JpaSort} for the given direction and {@link Path}s.
 	 *
 	 * @param direction the sorting direction.
-	 * @param paths     must not be {@literal null} or empty.
+	 * @param paths must not be {@literal null} or empty.
 	 */
 	public static JpaSort of(Direction direction, Path<?, ?>... paths) {
 		return new JpaSort(direction, Arrays.asList(paths));
@@ -96,10 +102,12 @@ public static JpaSort of(Direction direction, Path<?, ?>... paths) {
 	/**
 	 * Returns a new {@link JpaSort} with the given sorting criteria added to the current one.
 	 *
-	 * @param direction  can be {@literal null}.
+	 * @param direction can be {@literal null}.
 	 * @param attributes must not be {@literal null}.
 	 * @return
 	 */
+	@Contract("_, _ -> new")
+	@CheckReturnValue
 	public JpaSort and(@Nullable Direction direction, Attribute<?, ?>... attributes) {
 
 		Assert.notNull(attributes, "Attributes must not be null");
@@ -111,9 +119,11 @@ public JpaSort and(@Nullable Direction direction, Attribute<?, ?>... attributes)
 	 * Returns a new {@link JpaSort} with the given sorting criteria added to the current one.
 	 *
 	 * @param direction can be {@literal null}.
-	 * @param paths     must not be {@literal null}.
+	 * @param paths must not be {@literal null}.
 	 * @return
 	 */
+	@Contract("_, _ -> new")
+	@CheckReturnValue
 	public JpaSort and(@Nullable Direction direction, Path<?, ?>... paths) {
 
 		Assert.notNull(paths, "Paths must not be null");
@@ -130,10 +140,12 @@ public JpaSort and(@Nullable Direction direction, Path<?, ?>... paths) {
 	/**
 	 * Returns a new {@link JpaSort} with the given sorting criteria added to the current one.
 	 *
-	 * @param direction  can be {@literal null}.
+	 * @param direction can be {@literal null}.
 	 * @param properties must not be {@literal null} or empty.
 	 * @return
 	 */
+	@Contract("_, _ -> new")
+	@CheckReturnValue
 	public JpaSort andUnsafe(@Nullable Direction direction, String... properties) {
 
 		Assert.notEmpty(properties, "Properties must not be empty");
@@ -148,7 +160,7 @@ public JpaSort andUnsafe(@Nullable Direction direction, String... properties) {
 			orders.add(new JpaOrder(direction, property));
 		}
 
-		return new JpaSort(orders, direction, Collections.<Path<?, ?>>emptyList());
+		return new JpaSort(orders, direction, Collections.<Path<?, ?>> emptyList());
 	}
 
 	/**
@@ -219,7 +231,7 @@ public static JpaSort unsafe(String... properties) {
 	/**
 	 * Creates new unsafe {@link JpaSort} based on given {@link Direction} and properties.
 	 *
-	 * @param direction  must not be {@literal null}.
+	 * @param direction must not be {@literal null}.
 	 * @param properties must not be {@literal null} or empty.
 	 * @return
 	 */
@@ -235,7 +247,7 @@ public static JpaSort unsafe(Direction direction, String... properties) {
 	/**
 	 * Creates new unsafe {@link JpaSort} based on given {@link Direction} and properties.
 	 *
-	 * @param direction  must not be {@literal null}.
+	 * @param direction must not be {@literal null}.
 	 * @param properties must not be {@literal null} or empty.
 	 * @return
 	 */
@@ -271,6 +283,8 @@ private Path(List<? extends Attribute<?, ?>> attributes) {
 		 * @param attribute must not be {@literal null}.
 		 * @return
 		 */
+		@Contract("_ -> new")
+		@CheckReturnValue
 		public <A extends Attribute<S, U>, U> Path<S, U> dot(A attribute) {
 			return new Path<>(add(attribute));
 		}
@@ -281,6 +295,8 @@ public <A extends Attribute<S, U>, U> Path<S, U> dot(A attribute) {
 		 * @param attribute must not be {@literal null}.
 		 * @return
 		 */
+		@Contract("_ -> new")
+		@CheckReturnValue
 		public <P extends PluralAttribute<S, ?, U>, U> Path<S, U> dot(P attribute) {
 			return new Path<>(add(attribute));
 		}
@@ -327,7 +343,7 @@ public static class JpaOrder extends Order {
 		 * {@link Sort#DEFAULT_DIRECTION}
 		 *
 		 * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}.
-		 * @param property  must not be {@literal null}.
+		 * @param property must not be {@literal null}.
 		 */
 		private JpaOrder(@Nullable Direction direction, String property) {
 			this(direction, property, NullHandling.NATIVE);
@@ -337,8 +353,8 @@ private JpaOrder(@Nullable Direction direction, String property) {
 		 * Creates a new {@link Order} instance. if order is {@literal null} then order defaults to
 		 * {@link Sort#DEFAULT_DIRECTION}.
 		 *
-		 * @param direction        can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}.
-		 * @param property         must not be {@literal null}.
+		 * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}.
+		 * @param property must not be {@literal null}.
 		 * @param nullHandlingHint can be {@literal null}, will default to {@link NullHandling#NATIVE}.
 		 */
 		private JpaOrder(@Nullable Direction direction, String property, NullHandling nullHandlingHint) {
@@ -346,7 +362,7 @@ private JpaOrder(@Nullable Direction direction, String property, NullHandling nu
 		}
 
 		private JpaOrder(@Nullable Direction direction, String property, boolean ignoreCase, NullHandling nullHandling,
-						 boolean unsafe) {
+				boolean unsafe) {
 
 			super(direction, property, ignoreCase, nullHandling);
 			this.unsafe = unsafe;
@@ -368,6 +384,8 @@ public JpaOrder with(NullHandling nullHandling) {
 		 * @param properties must not be {@literal null}.
 		 * @return
 		 */
+		@Contract("_ -> new")
+		@CheckReturnValue
 		public Sort withUnsafe(String... properties) {
 
 			Assert.notEmpty(properties, "Properties must not be empty");
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/PredicateSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/PredicateSpecification.java
new file mode 100644
index 0000000000..daa39b9ba7
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/PredicateSpecification.java
@@ -0,0 +1,189 @@
+/*
+ * Copyright 2024 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.springframework.data.jpa.domain;
+
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.stream.StreamSupport;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.lang.CheckReturnValue;
+import org.springframework.lang.Contract;
+import org.springframework.util.Assert;
+
+/**
+ * Specification in the sense of Domain Driven Design.
+ * <p>
+ * Specifications can be composed into higher order functions from other specifications using
+ * {@link #and(PredicateSpecification)}, {@link #or(PredicateSpecification)} or factory methods such as
+ * {@link #allOf(Iterable)}. Composition considers whether one or more specifications contribute to the overall
+ * predicate by returning a {@link Predicate} or {@literal null}. Specifications returning {@literal null} are
+ * considered to not contribute to the overall predicate and their result is not considered in the final predicate.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+@FunctionalInterface
+public interface PredicateSpecification<T> extends Serializable {
+
+	/**
+	 * Simple static factory method to create a specification matching all objects.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal PredicateSpecification} operates on.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> PredicateSpecification<T> unrestricted() {
+		return (root, builder) -> null;
+	}
+
+	/**
+	 * Simple static factory method to add some syntactic sugar around a {@literal PredicateSpecification}.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal PredicateSpecification} operates on.
+	 * @param spec must not be {@literal null}.
+	 * @return guaranteed to be not {@literal null}.
+	 * @since 2.0
+	 */
+	static <T> PredicateSpecification<T> where(PredicateSpecification<T> spec) {
+
+		Assert.notNull(spec, "PredicateSpecification must not be null");
+
+		return spec;
+	}
+
+	/**
+	 * ANDs the given {@literal PredicateSpecification} to the current one.
+	 *
+	 * @param other the other {@link PredicateSpecification}.
+	 * @return the conjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default PredicateSpecification<T> and(PredicateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, other, CriteriaBuilder::and);
+	}
+
+	/**
+	 * ORs the given specification to the current one.
+	 *
+	 * @param other the other {@link PredicateSpecification}.
+	 * @return the disjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default PredicateSpecification<T> or(PredicateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, other, CriteriaBuilder::or);
+	}
+
+	/**
+	 * Negates the given {@link PredicateSpecification}.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal PredicateSpecification} operates on.
+	 * @param spec can be {@literal null}.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> PredicateSpecification<T> not(PredicateSpecification<T> spec) {
+
+		Assert.notNull(spec, "Specification must not be null");
+
+		return (root, builder) -> {
+
+			Predicate predicate = spec.toPredicate(root, builder);
+			return predicate != null ? builder.not(predicate) : builder.disjunction();
+		};
+	}
+
+	/**
+	 * Applies an AND operation to all the given {@link PredicateSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link PredicateSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link PredicateSpecification}s to compose.
+	 * @return the conjunction of the specifications.
+	 * @see #allOf(Iterable)
+	 * @see #and(PredicateSpecification)
+	 */
+	@SafeVarargs
+	static <T> PredicateSpecification<T> allOf(PredicateSpecification<T>... specifications) {
+		return allOf(Arrays.asList(specifications));
+	}
+
+	/**
+	 * Applies an AND operation to all the given {@link PredicateSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link PredicateSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link PredicateSpecification}s to compose.
+	 * @return the conjunction of the specifications.
+	 * @see #and(PredicateSpecification)
+	 * @see #allOf(PredicateSpecification[])
+	 */
+	static <T> PredicateSpecification<T> allOf(Iterable<PredicateSpecification<T>> specifications) {
+
+		return StreamSupport.stream(specifications.spliterator(), false) //
+				.reduce(PredicateSpecification.unrestricted(), PredicateSpecification::and);
+	}
+
+	/**
+	 * Applies an OR operation to all the given {@link PredicateSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link PredicateSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link PredicateSpecification}s to compose.
+	 * @return the disjunction of the specifications.
+	 * @see #or(PredicateSpecification)
+	 * @see #anyOf(Iterable)
+	 */
+	@SafeVarargs
+	static <T> PredicateSpecification<T> anyOf(PredicateSpecification<T>... specifications) {
+		return anyOf(Arrays.asList(specifications));
+	}
+
+	/**
+	 * Applies an OR operation to all the given {@link PredicateSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link PredicateSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link PredicateSpecification}s to compose.
+	 * @return the disjunction of the specifications.
+	 * @see #or(PredicateSpecification)
+	 * @see #anyOf(PredicateSpecification[])
+	 */
+	static <T> PredicateSpecification<T> anyOf(Iterable<PredicateSpecification<T>> specifications) {
+
+		return StreamSupport.stream(specifications.spliterator(), false) //
+				.reduce(PredicateSpecification.unrestricted(), PredicateSpecification::or);
+	}
+
+	/**
+	 * Creates a WHERE clause for a query of the referenced entity in form of a {@link Predicate} for the given
+	 * {@link Root} and {@link CriteriaBuilder}.
+	 *
+	 * @param root must not be {@literal null}.
+	 * @param criteriaBuilder must not be {@literal null}.
+	 * @return a {@link Predicate}, may be {@literal null}.
+	 */
+	@Nullable
+	Predicate toPredicate(Root<T> root, CriteriaBuilder criteriaBuilder);
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/Specification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/Specification.java
index 4586bf76f7..25a5fb2ce2 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/Specification.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/Specification.java
@@ -17,18 +17,28 @@
 
 import jakarta.persistence.criteria.CriteriaBuilder;
 import jakarta.persistence.criteria.CriteriaQuery;
+import jakarta.persistence.criteria.CriteriaUpdate;
 import jakarta.persistence.criteria.Predicate;
 import jakarta.persistence.criteria.Root;
 
-import java.io.Serial;
 import java.io.Serializable;
 import java.util.Arrays;
 import java.util.stream.StreamSupport;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.lang.CheckReturnValue;
+import org.springframework.lang.Contract;
+import org.springframework.util.Assert;
 
 /**
  * Specification in the sense of Domain Driven Design.
+ * <p>
+ * Specifications can be composed into higher order functions from other specifications using
+ * {@link #and(Specification)}, {@link #or(Specification)} or factory methods such as {@link #allOf(Iterable)}.
+ * Composition considers whether one or more specifications contribute to the overall predicate by returning a
+ * {@link Predicate} or {@literal null}. Specifications returning {@literal null} are considered to not contribute to
+ * the overall predicate and their result is not considered in the final predicate.
  *
  * @author Oliver Gierke
  * @author Thomas Darimont
@@ -42,86 +52,121 @@
 @FunctionalInterface
 public interface Specification<T> extends Serializable {
 
-	@Serial long serialVersionUID = 1L;
-
 	/**
-	 * Negates the given {@link Specification}.
+	 * Simple static factory method to create a specification matching all objects.
 	 *
 	 * @param <T> the type of the {@link Root} the resulting {@literal Specification} operates on.
-	 * @param spec can be {@literal null}.
 	 * @return guaranteed to be not {@literal null}.
-	 * @since 2.0
 	 */
-	static <T> Specification<T> not(@Nullable Specification<T> spec) {
-
-		return spec == null //
-				? (root, query, builder) -> null //
-				: (root, query, builder) -> builder.not(spec.toPredicate(root, query, builder));
+	static <T> Specification<T> unrestricted() {
+		return (root, query, builder) -> null;
 	}
 
 	/**
-	 * Simple static factory method to add some syntactic sugar around a {@link Specification}.
+	 * Simple static factory method to add some syntactic sugar translating {@link PredicateSpecification} to
+	 * {@link Specification}.
 	 *
 	 * @param <T> the type of the {@link Root} the resulting {@literal Specification} operates on.
-	 * @param spec can be {@literal null}.
+	 * @param spec the {@link PredicateSpecification} to wrap.
 	 * @return guaranteed to be not {@literal null}.
-	 * @since 2.0
-	 * @deprecated since 3.5.
 	 */
-	@Deprecated(since = "3.5.0", forRemoval = true)
-	static <T> Specification<T> where(@Nullable Specification<T> spec) {
-		return spec == null ? (root, query, builder) -> null : spec;
+	static <T> Specification<T> where(PredicateSpecification<T> spec) {
+
+		Assert.notNull(spec, "PredicateSpecification must not be null");
+
+		return (root, update, criteriaBuilder) -> spec.toPredicate(root, criteriaBuilder);
 	}
 
 	/**
 	 * ANDs the given {@link Specification} to the current one.
 	 *
-	 * @param other can be {@literal null}.
-	 * @return The conjunction of the specifications
+	 * @param other the other {@link Specification}.
+	 * @return the conjunction of the specifications.
 	 * @since 2.0
 	 */
-	default Specification<T> and(@Nullable Specification<T> other) {
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default Specification<T> and(Specification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
 		return SpecificationComposition.composed(this, other, CriteriaBuilder::and);
 	}
 
+	/**
+	 * ANDs the given {@link Specification} to the current one.
+	 *
+	 * @param other the other {@link PredicateSpecification}.
+	 * @return the conjunction of the specifications.
+	 * @since 2.0
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default Specification<T> and(PredicateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, where(other), CriteriaBuilder::and);
+	}
+
 	/**
 	 * ORs the given specification to the current one.
 	 *
-	 * @param other can be {@literal null}.
-	 * @return The disjunction of the specifications
+	 * @param other the other {@link Specification}.
+	 * @return the disjunction of the specifications
 	 * @since 2.0
 	 */
-	default Specification<T> or(@Nullable Specification<T> other) {
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default Specification<T> or(Specification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
 		return SpecificationComposition.composed(this, other, CriteriaBuilder::or);
 	}
 
 	/**
-	 * Creates a WHERE clause for a query of the referenced entity in form of a {@link Predicate} for the given
-	 * {@link Root} and {@link CriteriaQuery}.
+	 * ORs the given specification to the current one.
 	 *
-	 * @param root must not be {@literal null}.
-	 * @param query can be {@literal null} to allow overrides that accept {@link jakarta.persistence.criteria.CriteriaDelete} which is an {@link jakarta.persistence.criteria.AbstractQuery} but no {@link CriteriaQuery}.
-	 * @param criteriaBuilder must not be {@literal null}.
-	 * @return a {@link Predicate}, may be {@literal null}.
+	 * @param other the other {@link PredicateSpecification}.
+	 * @return the disjunction of the specifications
+	 * @since 2.0
 	 */
-	@Nullable
-	Predicate toPredicate(Root<T> root, @Nullable CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default Specification<T> or(PredicateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, where(other), CriteriaBuilder::or);
+	}
 
 	/**
-	 * Applies an AND operation to all the given {@link Specification}s.
+	 * Negates the given {@link Specification}.
 	 *
-	 * @param specifications The {@link Specification}s to compose. Can contain {@code null}s.
-	 * @return The conjunction of the specifications
-	 * @see #and(Specification)
-	 * @since 3.0
+	 * @param <T> the type of the {@link Root} the resulting {@literal Specification} operates on.
+	 * @param spec can be {@literal null}.
+	 * @return guaranteed to be not {@literal null}.
+	 * @since 2.0
 	 */
-	static <T> Specification<T> allOf(Iterable<Specification<T>> specifications) {
+	static <T> Specification<T> not(Specification<T> spec) {
 
-		return StreamSupport.stream(specifications.spliterator(), false) //
-				.reduce(Specification.where(null), Specification::and);
+		Assert.notNull(spec, "Specification must not be null");
+
+		return (root, query, builder) -> {
+
+			Predicate predicate = spec.toPredicate(root, query, builder);
+			return predicate != null ? builder.not(predicate) : builder.disjunction();
+		};
 	}
 
 	/**
+	 * Applies an AND operation to all the given {@link Specification}s. If {@code specifications} is empty, the resulting
+	 * {@link Specification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link Specification}s to compose.
+	 * @return the conjunction of the specifications.
+	 * @see #and(Specification)
 	 * @see #allOf(Iterable)
 	 * @since 3.0
 	 */
@@ -131,20 +176,28 @@ static <T> Specification<T> allOf(Specification<T>... specifications) {
 	}
 
 	/**
-	 * Applies an OR operation to all the given {@link Specification}s.
+	 * Applies an AND operation to all the given {@link Specification}s. If {@code specifications} is empty, the resulting
+	 * {@link Specification} will be unrestricted applying to all objects.
 	 *
-	 * @param specifications The {@link Specification}s to compose. Can contain {@code null}s.
-	 * @return The disjunction of the specifications
-	 * @see #or(Specification)
+	 * @param specifications the {@link Specification}s to compose.
+	 * @return the conjunction of the specifications.
+	 * @see #and(Specification)
+	 * @see #allOf(Specification[])
 	 * @since 3.0
 	 */
-	static <T> Specification<T> anyOf(Iterable<Specification<T>> specifications) {
+	static <T> Specification<T> allOf(Iterable<Specification<T>> specifications) {
 
 		return StreamSupport.stream(specifications.spliterator(), false) //
-				.reduce(Specification.where(null), Specification::or);
+				.reduce(Specification.unrestricted(), Specification::and);
 	}
 
 	/**
+	 * Applies an OR operation to all the given {@link Specification}s. If {@code specifications} is empty, the resulting
+	 * {@link Specification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link Specification}s to compose.
+	 * @return the disjunction of the specifications
+	 * @see #or(Specification)
 	 * @see #anyOf(Iterable)
 	 * @since 3.0
 	 */
@@ -152,4 +205,33 @@ static <T> Specification<T> anyOf(Iterable<Specification<T>> specifications) {
 	static <T> Specification<T> anyOf(Specification<T>... specifications) {
 		return anyOf(Arrays.asList(specifications));
 	}
+
+	/**
+	 * Applies an OR operation to all the given {@link Specification}s. If {@code specifications} is empty, the resulting
+	 * {@link Specification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link Specification}s to compose.
+	 * @return the disjunction of the specifications
+	 * @see #or(Specification)
+	 * @see #anyOf(Iterable)
+	 * @since 3.0
+	 */
+	static <T> Specification<T> anyOf(Iterable<Specification<T>> specifications) {
+
+		return StreamSupport.stream(specifications.spliterator(), false) //
+				.reduce(Specification.unrestricted(), Specification::or);
+	}
+
+	/**
+	 * Creates a WHERE clause for a query of the referenced entity in form of a {@link Predicate} for the given
+	 * {@link Root} and {@link CriteriaUpdate}.
+	 *
+	 * @param root must not be {@literal null}.
+	 * @param query the criteria query.
+	 * @param criteriaBuilder must not be {@literal null}.
+	 * @return a {@link Predicate}, may be {@literal null}.
+	 */
+	@Nullable
+	Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder);
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/SpecificationComposition.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/SpecificationComposition.java
index ad78749e39..0c73627bae 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/SpecificationComposition.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/SpecificationComposition.java
@@ -15,14 +15,17 @@
  */
 package org.springframework.data.jpa.domain;
 
-import java.io.Serializable;
-
 import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaDelete;
 import jakarta.persistence.criteria.CriteriaQuery;
+import jakarta.persistence.criteria.CriteriaUpdate;
 import jakarta.persistence.criteria.Predicate;
 import jakarta.persistence.criteria.Root;
 
-import org.springframework.lang.Nullable;
+import java.io.Serializable;
+
+import org.jspecify.annotations.Nullable;
+import org.springframework.lang.Contract;
 
 /**
  * Helper class to support specification compositions.
@@ -37,7 +40,8 @@
 class SpecificationComposition {
 
 	interface Combiner extends Serializable {
-		Predicate combine(CriteriaBuilder builder, @Nullable Predicate lhs, @Nullable Predicate rhs);
+		@Nullable
+		Predicate combine(CriteriaBuilder builder, Predicate lhs, Predicate rhs);
 	}
 
 	static <T> Specification<T> composed(@Nullable Specification<T> lhs, @Nullable Specification<T> rhs,
@@ -56,9 +60,76 @@ static <T> Specification<T> composed(@Nullable Specification<T> lhs, @Nullable S
 		};
 	}
 
-	@Nullable
-	private static <T> Predicate toPredicate(@Nullable Specification<T> specification, Root<T> root, @Nullable CriteriaQuery<?> query,
-			CriteriaBuilder builder) {
+	private static <T> @Nullable Predicate toPredicate(@Nullable Specification<T> specification, Root<T> root,
+			CriteriaQuery<?> query, CriteriaBuilder builder) {
 		return specification == null ? null : specification.toPredicate(root, query, builder);
 	}
+
+	@Contract("_, _, !null -> new")
+	@SuppressWarnings("NullAway")
+	static <T> DeleteSpecification<T> composed(@Nullable DeleteSpecification<T> lhs, @Nullable DeleteSpecification<T> rhs,
+			Combiner combiner) {
+
+		return (root, query, builder) -> {
+
+			Predicate thisPredicate = toPredicate(lhs, root, query, builder);
+			Predicate otherPredicate = toPredicate(rhs, root, query, builder);
+
+			if (thisPredicate == null) {
+				return otherPredicate;
+			}
+
+			return otherPredicate == null ? thisPredicate : combiner.combine(builder, thisPredicate, otherPredicate);
+		};
+	}
+
+	private static <T> @Nullable Predicate toPredicate(@Nullable DeleteSpecification<T> specification, Root<T> root,
+			@Nullable CriteriaDelete<T> delete, CriteriaBuilder builder) {
+
+		return specification == null || delete == null ? null : specification.toPredicate(root, delete, builder);
+	}
+
+	static <T> UpdateSpecification<T> composed(@Nullable UpdateSpecification<T> lhs, @Nullable UpdateSpecification<T> rhs,
+			Combiner combiner) {
+
+		return (root, query, builder) -> {
+
+			Predicate thisPredicate = toPredicate(lhs, root, query, builder);
+			Predicate otherPredicate = toPredicate(rhs, root, query, builder);
+
+			if (thisPredicate == null) {
+				return otherPredicate;
+			}
+
+			return otherPredicate == null ? thisPredicate : combiner.combine(builder, thisPredicate, otherPredicate);
+		};
+	}
+
+
+	private static <T> @Nullable Predicate toPredicate(@Nullable UpdateSpecification<T> specification, Root<T> root,
+			CriteriaUpdate<T> update, CriteriaBuilder builder) {
+		return specification == null ? null : specification.toPredicate(root, update, builder);
+	}
+
+	static <T> PredicateSpecification<T> composed(PredicateSpecification<T> lhs, PredicateSpecification<T> rhs,
+			Combiner combiner) {
+
+		return (root, builder) -> {
+
+			Predicate thisPredicate = toPredicate(lhs, root, builder);
+			Predicate otherPredicate = toPredicate(rhs, root, builder);
+
+			if (thisPredicate == null) {
+				return otherPredicate;
+			}
+
+			return otherPredicate == null ? thisPredicate : combiner.combine(builder, thisPredicate, otherPredicate);
+		};
+	}
+
+	private static <T> @Nullable Predicate toPredicate(@Nullable PredicateSpecification<T> specification, Root<T> root,
+			CriteriaBuilder builder) {
+		return specification == null ? null : specification.toPredicate(root, builder);
+	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/UpdateSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/UpdateSpecification.java
new file mode 100644
index 0000000000..1a27d428a4
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/UpdateSpecification.java
@@ -0,0 +1,339 @@
+/*
+ * Copyright 2024 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.springframework.data.jpa.domain;
+
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaUpdate;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+
+import java.io.Serializable;
+import java.util.Arrays;
+import java.util.stream.StreamSupport;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.lang.CheckReturnValue;
+import org.springframework.lang.Contract;
+import org.springframework.util.Assert;
+
+/**
+ * Specification in the sense of Domain Driven Design to handle Criteria Updates.
+ * <p>
+ * Specifications can be composed into higher order functions from other specifications using
+ * {@link #and(UpdateSpecification)}, {@link #or(UpdateSpecification)} or factory methods such as
+ * {@link #allOf(Iterable)}. Composition considers whether one or more specifications contribute to the overall
+ * predicate by returning a {@link Predicate} or {@literal null}. Specifications returning {@literal null} are
+ * considered to not contribute to the overall predicate and their result is not considered in the final predicate.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+@FunctionalInterface
+public interface UpdateSpecification<T> extends Serializable {
+
+	/**
+	 * Simple static factory method to create a specification updating all objects.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal UpdateSpecification} operates on.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> UpdateSpecification<T> unrestricted() {
+		return (root, query, builder) -> null;
+	}
+
+	/**
+	 * Simple static factory method to add some syntactic sugar around a {@literal UpdateOperation}. For example:
+	 *
+	 * <pre class="code">
+	 * UpdateSpecification&lt;User&gt; updateLastname = UpdateOperation
+	 * 		.&lt;User&gt; update((root, update, criteriaBuilder) -> update.set("lastname", "Heisenberg"))
+	 * 		.where(userHasFirstname("Walter").and(userHasLastname("White")));
+	 *
+	 * repository.update(updateLastname);
+	 * </pre>
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal UpdateOperation} operates on.
+	 * @param spec must not be {@literal null}.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> UpdateOperation<T> update(UpdateOperation<T> spec) {
+
+		Assert.notNull(spec, "UpdateSpecification must not be null");
+
+		return spec;
+	}
+
+	/**
+	 * Simple static factory method to add some syntactic sugar around a {@literal UpdateSpecification}.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal UpdateSpecification} operates on.
+	 * @param spec must not be {@literal null}.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> UpdateSpecification<T> where(UpdateSpecification<T> spec) {
+
+		Assert.notNull(spec, "UpdateSpecification must not be null");
+
+		return spec;
+	}
+
+	/**
+	 * Simple static factory method to add some syntactic sugar translating {@link PredicateSpecification} to
+	 * {@link UpdateSpecification}.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal UpdateSpecification} operates on.
+	 * @param spec the {@link PredicateSpecification} to wrap.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> UpdateSpecification<T> where(PredicateSpecification<T> spec) {
+
+		Assert.notNull(spec, "PredicateSpecification must not be null");
+
+		return where((root, update, criteriaBuilder) -> spec.toPredicate(root, criteriaBuilder));
+	}
+
+	/**
+	 * ANDs the given {@link UpdateSpecification} to the current one.
+	 *
+	 * @param other the other {@link UpdateSpecification}.
+	 * @return the conjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default UpdateSpecification<T> and(UpdateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, other, CriteriaBuilder::and);
+	}
+
+	/**
+	 * ANDs the given {@link UpdateSpecification} to the current one.
+	 *
+	 * @param other the other {@link PredicateSpecification}.
+	 * @return the conjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default UpdateSpecification<T> and(PredicateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, where(other), CriteriaBuilder::and);
+	}
+
+	/**
+	 * ORs the given specification to the current one.
+	 *
+	 * @param other the other {@link UpdateSpecification}.
+	 * @return the disjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default UpdateSpecification<T> or(UpdateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, other, CriteriaBuilder::or);
+	}
+
+	/**
+	 * ORs the given specification to the current one.
+	 *
+	 * @param other the other {@link PredicateSpecification}.
+	 * @return the disjunction of the specifications.
+	 */
+	@Contract("_ -> new")
+	@CheckReturnValue
+	default UpdateSpecification<T> or(PredicateSpecification<T> other) {
+
+		Assert.notNull(other, "Other specification must not be null");
+
+		return SpecificationComposition.composed(this, where(other), CriteriaBuilder::or);
+	}
+
+	/**
+	 * Negates the given {@link UpdateSpecification}.
+	 *
+	 * @param <T> the type of the {@link Root} the resulting {@literal UpdateSpecification} operates on.
+	 * @param spec can be {@literal null}.
+	 * @return guaranteed to be not {@literal null}.
+	 */
+	static <T> UpdateSpecification<T> not(UpdateSpecification<T> spec) {
+
+		Assert.notNull(spec, "Specification must not be null");
+
+		return (root, update, builder) -> {
+
+			Predicate predicate = spec.toPredicate(root, update, builder);
+			return predicate != null ? builder.not(predicate) : builder.disjunction();
+		};
+	}
+
+	/**
+	 * Applies an AND operation to all the given {@link UpdateSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link UpdateSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link UpdateSpecification}s to compose.
+	 * @return the conjunction of the specifications.
+	 * @see #and(UpdateSpecification)
+	 * @see #allOf(Iterable)
+	 */
+	@SafeVarargs
+	static <T> UpdateSpecification<T> allOf(UpdateSpecification<T>... specifications) {
+		return allOf(Arrays.asList(specifications));
+	}
+
+	/**
+	 * Applies an AND operation to all the given {@link UpdateSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link UpdateSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link UpdateSpecification}s to compose.
+	 * @return the conjunction of the specifications.
+	 * @see #and(UpdateSpecification)
+	 * @see #allOf(UpdateSpecification[])
+	 */
+	static <T> UpdateSpecification<T> allOf(Iterable<UpdateSpecification<T>> specifications) {
+
+		return StreamSupport.stream(specifications.spliterator(), false) //
+				.reduce(UpdateSpecification.unrestricted(), UpdateSpecification::and);
+	}
+
+	/**
+	 * Applies an OR operation to all the given {@link UpdateSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link UpdateSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link UpdateSpecification}s to compose.
+	 * @return the disjunction of the specifications.
+	 * @see #or(UpdateSpecification)
+	 * @see #anyOf(Iterable)
+	 */
+	@SafeVarargs
+	static <T> UpdateSpecification<T> anyOf(UpdateSpecification<T>... specifications) {
+		return anyOf(Arrays.asList(specifications));
+	}
+
+	/**
+	 * Applies an OR operation to all the given {@link UpdateSpecification}s. If {@code specifications} is empty, the
+	 * resulting {@link UpdateSpecification} will be unrestricted applying to all objects.
+	 *
+	 * @param specifications the {@link UpdateSpecification}s to compose.
+	 * @return the disjunction of the specifications.
+	 * @see #or(UpdateSpecification)
+	 * @see #anyOf(Iterable)
+	 */
+	static <T> UpdateSpecification<T> anyOf(Iterable<UpdateSpecification<T>> specifications) {
+
+		return StreamSupport.stream(specifications.spliterator(), false) //
+				.reduce(UpdateSpecification.unrestricted(), UpdateSpecification::or);
+	}
+
+	/**
+	 * Creates a WHERE clause for a query of the referenced entity in form of a {@link Predicate} for the given
+	 * {@link Root} and {@link CriteriaUpdate}.
+	 *
+	 * @param root must not be {@literal null}.
+	 * @param update the update criteria.
+	 * @param criteriaBuilder must not be {@literal null}.
+	 * @return a {@link Predicate}, may be {@literal null}.
+	 */
+	@Nullable
+	Predicate toPredicate(Root<T> root, CriteriaUpdate<T> update, CriteriaBuilder criteriaBuilder);
+
+	/**
+	 * Simplified extension to {@link UpdateSpecification} that only considers the {@code UPDATE} part without specifying
+	 * a predicate. This is useful to separate concerns for reusable specifications, for example:
+	 *
+	 * <pre class="code">
+	 * UpdateSpecification&lt;User&gt; updateLastname = UpdateSpecification
+	 * 		.&lt;User&gt; update((root, update, criteriaBuilder) -> update.set("lastname", "Heisenberg"))
+	 * 		.where(userHasFirstname("Walter").and(userHasLastname("White")));
+	 *
+	 * repository.update(updateLastname);
+	 * </pre>
+	 *
+	 * @param <T>
+	 */
+	@FunctionalInterface
+	interface UpdateOperation<T> {
+
+		/**
+		 * ANDs the given {@link UpdateOperation} to the current one.
+		 *
+		 * @param other the other {@link UpdateOperation}.
+		 * @return the conjunction of the specifications.
+		 */
+		@Contract("_ -> new")
+		@CheckReturnValue
+		default UpdateOperation<T> and(UpdateOperation<T> other) {
+
+			Assert.notNull(other, "Other UpdateOperation must not be null");
+
+			return (root, update, criteriaBuilder) -> {
+				this.apply(root, update, criteriaBuilder);
+				other.apply(root, update, criteriaBuilder);
+			};
+		}
+
+		/**
+		 * Creates a {@link UpdateSpecification} from this and the given {@link UpdateSpecification}.
+		 *
+		 * @param specification the {@link PredicateSpecification}.
+		 * @return the conjunction of the specifications.
+		 */
+		@Contract("_ -> new")
+		@CheckReturnValue
+		default UpdateSpecification<T> where(PredicateSpecification<T> specification) {
+
+			Assert.notNull(specification, "PredicateSpecification must not be null");
+
+			return (root, update, criteriaBuilder) -> {
+				this.apply(root, update, criteriaBuilder);
+				return specification.toPredicate(root, criteriaBuilder);
+			};
+		}
+
+		/**
+		 * Creates a {@link UpdateSpecification} from this and the given {@link UpdateSpecification}.
+		 *
+		 * @param specification the {@link UpdateSpecification}.
+		 * @return the conjunction of the specifications.
+		 */
+		@Contract("_ -> new")
+		@CheckReturnValue
+		default UpdateSpecification<T> where(UpdateSpecification<T> specification) {
+
+			Assert.notNull(specification, "UpdateSpecification must not be null");
+
+			return (root, update, criteriaBuilder) -> {
+				this.apply(root, update, criteriaBuilder);
+				return specification.toPredicate(root, update, criteriaBuilder);
+			};
+		}
+
+		/**
+		 * Accept the given {@link Root} and {@link CriteriaUpdate} to apply the update operation.
+		 *
+		 * @param root must not be {@literal null}.
+		 * @param update the update criteria.
+		 * @param criteriaBuilder must not be {@literal null}.
+		 */
+		void apply(Root<T> root, CriteriaUpdate<T> update, CriteriaBuilder criteriaBuilder);
+
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/package-info.java
index 46adc19c0a..2ee320ed30 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/package-info.java
@@ -1,5 +1,5 @@
 /**
  * JPA specific support classes to implement domain classes.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.domain;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingEntityListener.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingEntityListener.java
index 9dc73af957..9c0fe493ca 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingEntityListener.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingEntityListener.java
@@ -19,10 +19,11 @@
 import jakarta.persistence.PreUpdate;
 
 import org.springframework.beans.factory.ObjectFactory;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.beans.factory.annotation.Configurable;
 import org.springframework.data.auditing.AuditingHandler;
 import org.springframework.data.domain.Auditable;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/package-info.java
index d14b03294c..b18b18cf18 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Implementation classes for auditing with JPA.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.domain.support;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContext.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContext.java
index bc5a71c25c..2c0b813370 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContext.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContext.java
@@ -22,6 +22,8 @@
 import java.util.function.Predicate;
 
 import org.springframework.data.jpa.provider.PersistenceProvider;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.util.JpaMetamodel;
 import org.springframework.data.mapping.PersistentPropertyPaths;
 import org.springframework.data.mapping.context.AbstractMappingContext;
@@ -29,7 +31,6 @@
 import org.springframework.data.mapping.model.Property;
 import org.springframework.data.mapping.model.SimpleTypeHolder;
 import org.springframework.data.util.TypeInformation;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
@@ -114,8 +115,7 @@ private Metamodels(Set<Metamodel> metamodels) {
 		 * @param type must not be {@literal null}.
 		 * @return
 		 */
-		@Nullable
-		public JpaMetamodel getMetamodel(TypeInformation<?> type) {
+		public @Nullable JpaMetamodel getMetamodel(TypeInformation<?> type) {
 
 			Metamodel metamodel = getMetamodelFor(type.getType());
 
@@ -166,8 +166,7 @@ public boolean isMetamodelManagedType(Class<?> type) {
 		 * @param type must not be {@literal null}.
 		 * @return can be {@literal null}.
 		 */
-		@Nullable
-		private Metamodel getMetamodelFor(Class<?> type) {
+		private @Nullable Metamodel getMetamodelFor(Class<?> type) {
 
 			for (Metamodel model : metamodels) {
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntityImpl.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntityImpl.java
index 761a1600d0..611c82dff5 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntityImpl.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntityImpl.java
@@ -17,6 +17,7 @@
 
 import java.util.Comparator;
 
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.annotation.Version;
 import org.springframework.data.jpa.provider.ProxyIdAccessor;
 import org.springframework.data.jpa.util.JpaMetamodel;
@@ -63,7 +64,7 @@ public JpaPersistentEntityImpl(TypeInformation<T> information, ProxyIdAccessor p
 	}
 
 	@Override
-	protected JpaPersistentProperty returnPropertyIfBetterIdPropertyCandidateOrNull(JpaPersistentProperty property) {
+	protected @Nullable JpaPersistentProperty returnPropertyIfBetterIdPropertyCandidateOrNull(JpaPersistentProperty property) {
 		return property.isIdProperty() ? property : null;
 	}
 
@@ -117,7 +118,7 @@ private static class JpaProxyAwareIdentifierAccessor extends IdPropertyIdentifie
 		}
 
 		@Override
-		public Object getIdentifier() {
+		public @Nullable Object getIdentifier() {
 
 			return proxyIdAccessor.shouldUseAccessorFor(bean) //
 					? proxyIdAccessor.getIdentifierFrom(bean)//
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImpl.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImpl.java
index da773247e1..38678add2b 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImpl.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImpl.java
@@ -25,6 +25,8 @@
 import java.util.Set;
 
 import org.springframework.core.annotation.AnnotationUtils;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.annotation.AccessType.Type;
 import org.springframework.data.jpa.util.JpaMetamodel;
 import org.springframework.data.mapping.Association;
@@ -34,7 +36,6 @@
 import org.springframework.data.mapping.model.SimpleTypeHolder;
 import org.springframework.data.util.Lazy;
 import org.springframework.data.util.TypeInformation;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
@@ -57,13 +58,9 @@ class JpaPersistentPropertyImpl extends AnnotationBasedPersistentProperty<JpaPer
 
 	static {
 
-		Set<Class<? extends Annotation>> annotations = new HashSet<>();
-		annotations.add(OneToMany.class);
-		annotations.add(OneToOne.class);
-		annotations.add(ManyToMany.class);
-		annotations.add(ManyToOne.class);
+		Set<Class<? extends Annotation>> annotations;
 
-		ASSOCIATION_ANNOTATIONS = Collections.unmodifiableSet(annotations);
+        ASSOCIATION_ANNOTATIONS = Set.of(OneToMany.class, OneToOne.class, ManyToMany.class, ManyToOne.class);
 
 		annotations = new HashSet<>();
 		annotations.add(Id.class);
@@ -107,7 +104,7 @@ public JpaPersistentPropertyImpl(JpaMetamodel metamodel, Property property,
 		this.associationTargetType = detectAssociationTargetType();
 		this.updateable = detectUpdatability();
 
-		this.isIdProperty = Lazy.of(() -> ID_ANNOTATIONS.stream().anyMatch(it -> isAnnotationPresent(it)) //
+		this.isIdProperty = Lazy.of(() -> ID_ANNOTATIONS.stream().anyMatch(this::isAnnotationPresent) //
 				|| metamodel.isSingleIdAttribute(getOwner().getType(), getName(), getType()));
 		this.isEntity = Lazy.of(() -> metamodel.isMappedType(getActualType()));
 	}
@@ -174,7 +171,7 @@ public boolean isEmbeddable() {
 	}
 
 	@Override
-	public TypeInformation<?> getAssociationTargetTypeInformation() {
+	public @Nullable TypeInformation<?> getAssociationTargetTypeInformation() {
 
 		if (!isAssociation()) {
 			return null;
@@ -197,8 +194,7 @@ public TypeInformation<?> getAssociationTargetTypeInformation() {
 	 *
 	 * @return
 	 */
-	@Nullable
-	private Boolean detectPropertyAccess() {
+	private @Nullable Boolean detectPropertyAccess() {
 
 		org.springframework.data.annotation.AccessType accessType = findAnnotation(
 				org.springframework.data.annotation.AccessType.class);
@@ -233,8 +229,7 @@ private Boolean detectPropertyAccess() {
 	 *
 	 * @return
 	 */
-	@Nullable
-	private TypeInformation<?> detectAssociationTargetType() {
+	private @Nullable TypeInformation<?> detectAssociationTargetType() {
 
 		if (!isAssociation()) {
 			return null;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/package-info.java
index 0139f824dc..a16f60cc6f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/package-info.java
@@ -1,5 +1,5 @@
 /**
  * JPA specific support classes for the Spring Data mapping subsystem.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.mapping;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/projection/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/projection/package-info.java
new file mode 100644
index 0000000000..037c3c5eb3
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/projection/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * JPA specific support projection support.
+ */
+@org.jspecify.annotations.NullMarked
+package org.springframework.data.jpa.projection;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/HibernateUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/HibernateUtils.java
index 862cb5a1fb..f185237d4b 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/HibernateUtils.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/HibernateUtils.java
@@ -15,9 +15,12 @@
  */
 package org.springframework.data.jpa.provider;
 
+import org.hibernate.query.NativeQuery;
 import org.hibernate.query.Query;
 import org.hibernate.query.spi.SqmQuery;
-import org.springframework.lang.Nullable;
+import org.hibernate.query.sql.spi.NamedNativeQueryMemento;
+import org.hibernate.query.sqm.spi.NamedSqmQueryMemento;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Utility functions to work with Hibernate. Mostly using reflection to make sure common functionality can be executed
@@ -41,11 +44,9 @@ private HibernateUtils() {}
 	 * @param query
 	 * @return
 	 */
-	@Nullable
-	public static String getHibernateQuery(Object query) {
+	public @Nullable static String getHibernateQuery(Object query) {
 
 		try {
-
 			// Try the new Hibernate implementation first
 			if (query instanceof SqmQuery sqmQuery) {
 
@@ -58,6 +59,22 @@ public static String getHibernateQuery(Object query) {
 				return sqmQuery.getSqmStatement().toHqlString();
 			}
 
+			// Try the new Hibernate implementation first
+			if (query instanceof NamedSqmQueryMemento<?> sqmQuery) {
+
+				String hql = sqmQuery.getHqlString();
+
+				if (!hql.equals("<criteria>")) {
+					return hql;
+				}
+
+				return sqmQuery.getSqmStatement().toHqlString();
+			}
+
+			if (query instanceof NamedNativeQueryMemento<?> nativeQuery) {
+				return nativeQuery.getSqlString();
+			}
+
 			// Couple of cases in which this still breaks, see HHH-15389
 		} catch (RuntimeException o_O) {}
 
@@ -68,4 +85,28 @@ public static String getHibernateQuery(Object query) {
 			throw new IllegalArgumentException("Don't know how to extract the query string from " + query);
 		}
 	}
+
+	public static boolean isNativeQuery(Object query) {
+
+		// Try the new Hibernate implementation first
+		if (query instanceof SqmQuery) {
+			return false;
+		}
+
+		if (query instanceof NativeQuery<?>) {
+			return true;
+		}
+
+		// Try the new Hibernate implementation first
+		if (query instanceof NamedSqmQueryMemento<?>) {
+
+			return false;
+		}
+
+		if (query instanceof NamedNativeQueryMemento<?>) {
+			return true;
+		}
+
+		return false;
+	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/JpaClassUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/JpaClassUtils.java
index f00f4b849d..71971df3bf 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/JpaClassUtils.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/JpaClassUtils.java
@@ -18,7 +18,8 @@
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.metamodel.Metamodel;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
 
@@ -58,7 +59,7 @@ public static boolean isMetamodelOfType(Metamodel metamodel, String type) {
 		return isOfType(metamodel, type, metamodel.getClass().getClassLoader());
 	}
 
-	private static boolean isOfType(Object source, String typeName, @Nullable ClassLoader classLoader) {
+	public static boolean isOfType(Object source, String typeName, @Nullable ClassLoader classLoader) {
 
 		Assert.notNull(source, "Source instance must not be null");
 		Assert.hasText(typeName, "Target type name must not be null or empty");
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java
index 2b5e0abbeb..9755f19f09 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java
@@ -19,6 +19,7 @@
 import static org.springframework.data.jpa.provider.PersistenceProvider.Constants.*;
 
 import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
 import jakarta.persistence.Query;
 import jakarta.persistence.metamodel.IdentifiableType;
 import jakarta.persistence.metamodel.Metamodel;
@@ -29,6 +30,7 @@
 import java.util.List;
 import java.util.NoSuchElementException;
 import java.util.Set;
+import java.util.function.LongSupplier;
 
 import org.eclipse.persistence.config.QueryHints;
 import org.eclipse.persistence.jpa.JpaQuery;
@@ -36,8 +38,10 @@
 import org.hibernate.ScrollMode;
 import org.hibernate.ScrollableResults;
 import org.hibernate.proxy.HibernateProxy;
+import org.hibernate.query.SelectionQuery;
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.util.CloseableIterator;
-import org.springframework.lang.Nullable;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
@@ -64,14 +68,20 @@ public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor, Quer
 	 * @see <a href="https://github.com/spring-projects/spring-data-jpa/issues/846">DATAJPA-444</a>
 	 */
 	HIBERNATE(//
+			Collections.singletonList(HIBERNATE_ENTITY_MANAGER_FACTORY_INTERFACE), //
 			Collections.singletonList(HIBERNATE_ENTITY_MANAGER_INTERFACE), //
 			Collections.singletonList(HIBERNATE_JPA_METAMODEL_TYPE)) {
 
 		@Override
-		public String extractQueryString(Query query) {
+		public @Nullable String extractQueryString(Object query) {
 			return HibernateUtils.getHibernateQuery(query);
 		}
 
+		@Override
+		public boolean isNativeQuery(Object query) {
+			return HibernateUtils.isNativeQuery(query);
+		}
+
 		/**
 		 * Return custom placeholder ({@code *}) as Hibernate does create invalid queries for count queries for objects with
 		 * compound keys.
@@ -109,27 +119,43 @@ public String getCommentHintKey() {
 			return "org.hibernate.comment";
 		}
 
+		@Override
+		public long getResultCount(Query resultQuery, LongSupplier countSupplier) {
+
+			if (TransactionSynchronizationManager.isActualTransactionActive()
+					&& resultQuery instanceof SelectionQuery<?> sq) {
+				return sq.getResultCount();
+			}
+
+			return super.getResultCount(resultQuery, countSupplier);
+		}
+
 	},
 
 	/**
 	 * EclipseLink persistence provider.
 	 */
-	ECLIPSELINK(Collections.singleton(ECLIPSELINK_ENTITY_MANAGER_INTERFACE),
+	ECLIPSELINK(List.of(ECLIPSELINK_ENTITY_MANAGER_FACTORY_INTERFACE1, ECLIPSELINK_ENTITY_MANAGER_FACTORY_INTERFACE2),
+			Collections.singleton(ECLIPSELINK_ENTITY_MANAGER_INTERFACE),
 			Collections.singleton(ECLIPSELINK_JPA_METAMODEL_TYPE)) {
 
 		@Override
-		public String extractQueryString(Query query) {
+		public String extractQueryString(Object query) {
 			return ((JpaQuery<?>) query).getDatabaseQuery().getJPQLString();
 		}
 
+		@Override
+		public boolean isNativeQuery(Object query) {
+			return false;
+		}
+
 		@Override
 		public boolean shouldUseAccessorFor(Object entity) {
 			return false;
 		}
 
-		@Nullable
 		@Override
-		public Object getIdentifierFrom(Object entity) {
+		public @Nullable Object getIdentifierFrom(Object entity) {
 			return null;
 		}
 
@@ -147,19 +173,25 @@ public String getCommentHintKey() {
 		public String getCommentHintValue(String comment) {
 			return "/* " + comment + " */";
 		}
+
 	},
 
 	/**
 	 * Unknown special provider. Use standard JPA.
 	 */
-	GENERIC_JPA(Collections.singleton(GENERIC_JPA_ENTITY_MANAGER_INTERFACE), Collections.emptySet()) {
+	GENERIC_JPA(Collections.singleton(GENERIC_JPA_ENTITY_MANAGER_INTERFACE),
+			Collections.singleton(GENERIC_JPA_ENTITY_MANAGER_INTERFACE), Collections.emptySet()) {
 
-		@Nullable
 		@Override
-		public String extractQueryString(Query query) {
+		public @Nullable String extractQueryString(Object query) {
 			return null;
 		}
 
+		@Override
+		public boolean isNativeQuery(Object query) {
+			return false;
+		}
+
 		@Override
 		public boolean canExtractQuery() {
 			return false;
@@ -170,20 +202,19 @@ public boolean shouldUseAccessorFor(Object entity) {
 			return false;
 		}
 
-		@Nullable
 		@Override
-		public Object getIdentifierFrom(Object entity) {
+		public @Nullable Object getIdentifierFrom(Object entity) {
 			return null;
 		}
 
-		@Nullable
 		@Override
-		public String getCommentHintKey() {
+		public @Nullable String getCommentHintKey() {
 			return null;
 		}
+
 	};
 
-	private static final Class<?> typedParameterValueClass;
+	private static final @Nullable Class<?> typedParameterValueClass;
 
 	static {
 
@@ -199,6 +230,7 @@ public String getCommentHintKey() {
 	private static final Collection<PersistenceProvider> ALL = List.of(HIBERNATE, ECLIPSELINK, GENERIC_JPA);
 
 	private static final ConcurrentReferenceHashMap<Class<?>, PersistenceProvider> CACHE = new ConcurrentReferenceHashMap<>();
+	private final Iterable<String> entityManagerFactoryClassNames;
 	private final Iterable<String> entityManagerClassNames;
 	private final Iterable<String> metamodelClassNames;
 
@@ -207,24 +239,38 @@ public String getCommentHintKey() {
 	/**
 	 * Creates a new {@link PersistenceProvider}.
 	 *
+	 * @param entityManagerFactoryClassNames the names of the provider specific
+	 *          {@link jakarta.persistence.EntityManagerFactory} implementations. Must not be {@literal null} or empty.
 	 * @param entityManagerClassNames the names of the provider specific {@link EntityManager} implementations. Must not
 	 *          be {@literal null} or empty.
 	 * @param metamodelClassNames must not be {@literal null}.
 	 */
-	PersistenceProvider(Iterable<String> entityManagerClassNames, Iterable<String> metamodelClassNames) {
+	PersistenceProvider(Iterable<String> entityManagerFactoryClassNames, Iterable<String> entityManagerClassNames,
+			Iterable<String> metamodelClassNames) {
 
+		this.entityManagerFactoryClassNames = entityManagerFactoryClassNames;
 		this.entityManagerClassNames = entityManagerClassNames;
 		this.metamodelClassNames = metamodelClassNames;
 
 		boolean present = false;
-		for (String entityManagerClassName : entityManagerClassNames) {
+		for (String emfClassName : entityManagerFactoryClassNames) {
 
-			if (ClassUtils.isPresent(entityManagerClassName, PersistenceProvider.class.getClassLoader())) {
+			if (ClassUtils.isPresent(emfClassName, PersistenceProvider.class.getClassLoader())) {
 				present = true;
 				break;
 			}
 		}
 
+		if (!present) {
+			for (String entityManagerClassName : entityManagerClassNames) {
+
+				if (ClassUtils.isPresent(entityManagerClassName, PersistenceProvider.class.getClassLoader())) {
+					present = true;
+					break;
+				}
+			}
+		}
+
 		this.present = present;
 	}
 
@@ -269,6 +315,36 @@ public static PersistenceProvider fromEntityManager(EntityManager em) {
 		return cacheAndReturn(entityManagerType, GENERIC_JPA);
 	}
 
+	/**
+	 * Determines the {@link PersistenceProvider} from the given {@link EntityManager}. If no special one can be
+	 * determined {@link #GENERIC_JPA} will be returned.
+	 *
+	 * @param emf must not be {@literal null}.
+	 * @return will never be {@literal null}.
+	 */
+	public static PersistenceProvider fromEntityManagerFactory(EntityManagerFactory emf) {
+
+		Assert.notNull(emf, "EntityManager must not be null");
+
+		Class<?> entityManagerType = emf.getPersistenceUnitUtil().getClass();
+		PersistenceProvider cachedProvider = CACHE.get(entityManagerType);
+
+		if (cachedProvider != null) {
+			return cachedProvider;
+		}
+
+		for (PersistenceProvider provider : ALL) {
+			for (String emfClassName : provider.entityManagerFactoryClassNames) {
+				if (isOfType(emf.getPersistenceUnitUtil(), emfClassName,
+						emf.getPersistenceUnitUtil().getClass().getClassLoader())) {
+					return cacheAndReturn(entityManagerType, provider);
+				}
+			}
+		}
+
+		return cacheAndReturn(entityManagerType, GENERIC_JPA);
+	}
+
 	/**
 	 * Determines the {@link PersistenceProvider} from the given {@link Metamodel}. If no special one can be determined
 	 * {@link #GENERIC_JPA} will be returned.
@@ -334,8 +410,7 @@ public boolean canExtractQuery() {
 	 * @return the original value or null.
 	 * @since 3.0
 	 */
-	@Nullable
-	public static Object unwrapTypedParameterValue(@Nullable Object value) {
+	public static @Nullable Object unwrapTypedParameterValue(@Nullable Object value) {
 
 		return typedParameterValueClass != null && typedParameterValueClass.isInstance(value) //
 				? null //
@@ -346,6 +421,18 @@ public boolean isPresent() {
 		return this.present;
 	}
 
+	/**
+	 * Obtain the result count from a {@link Query} returning the result or fall back to {@code countSupplier} if the
+	 * query does not provide the result count.
+	 *
+	 * @param resultQuery the query that has returned {@link Query#getResultList()}
+	 * @param countSupplier fallback supplier to provide the count if the query does not provide it.
+	 * @return the result count.
+	 */
+	public long getResultCount(Query resultQuery, LongSupplier countSupplier) {
+		return countSupplier.getAsLong();
+	}
+
 	/**
 	 * Holds the PersistenceProvider specific interface names.
 	 *
@@ -354,13 +441,20 @@ public boolean isPresent() {
 	 */
 	interface Constants {
 
+		String GENERIC_JPA_ENTITY_MANAGER_FACTORY_INTERFACE = "jakarta.persistence.EntityManagerFactory";
 		String GENERIC_JPA_ENTITY_MANAGER_INTERFACE = "jakarta.persistence.EntityManager";
+
+		String ECLIPSELINK_ENTITY_MANAGER_FACTORY_INTERFACE1 = "org.eclipse.persistence.internal.jpa.EntityManagerFactoryDelegate";
+		String ECLIPSELINK_ENTITY_MANAGER_FACTORY_INTERFACE2 = "org.eclipse.persistence.internal.jpa.EntityManagerFactoryImpl";
 		String ECLIPSELINK_ENTITY_MANAGER_INTERFACE = "org.eclipse.persistence.jpa.JpaEntityManager";
+
 		// needed as Spring only exposes that interface via the EM proxy
+		String HIBERNATE_ENTITY_MANAGER_FACTORY_INTERFACE = "org.hibernate.jpa.internal.PersistenceUnitUtilImpl";
 		String HIBERNATE_ENTITY_MANAGER_INTERFACE = "org.hibernate.engine.spi.SessionImplementor";
 
 		String HIBERNATE_JPA_METAMODEL_TYPE = "org.hibernate.metamodel.model.domain.JpaMetamodel";
 		String ECLIPSELINK_JPA_METAMODEL_TYPE = "org.eclipse.persistence.internal.jpa.metamodel.MetamodelImpl";
+
 	}
 
 	public CloseableIterator<Object> executeQueryWithResultStream(Query jpaQuery) {
@@ -416,6 +510,7 @@ public void close() {
 				scrollableResults.close();
 			}
 		}
+
 	}
 
 	/**
@@ -465,5 +560,7 @@ public void close() {
 				scrollableCursor.close();
 			}
 		}
+
 	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/ProxyIdAccessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/ProxyIdAccessor.java
index d999d7490b..e550368876 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/ProxyIdAccessor.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/ProxyIdAccessor.java
@@ -15,7 +15,7 @@
  */
 package org.springframework.data.jpa.provider;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Interface for a persistence provider specific accessor of identifiers held in proxies.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java
index aa39144da4..aa6c64abaf 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java
@@ -17,7 +17,7 @@
 
 import jakarta.persistence.Query;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Interface to hide different implementations of query hints that insert comments into a {@link Query}.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryExtractor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryExtractor.java
index 6bd6f4bace..6d25429525 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryExtractor.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryExtractor.java
@@ -16,8 +16,9 @@
 package org.springframework.data.jpa.provider;
 
 import jakarta.persistence.Query;
+import jakarta.persistence.TypedQueryReference;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Interface to hide different implementations to extract the original JPA query string from a {@link Query}.
@@ -28,14 +29,25 @@
 public interface QueryExtractor {
 
 	/**
-	 * Reverse engineers the query string from the {@link Query} object. This requires provider specific API as JPA does
-	 * not provide access to the underlying query string as soon as one has created a {@link Query} instance of it.
+	 * Reverse engineers the query string from the {@link Query} or a {@link TypedQueryReference} object. This requires
+	 * provider specific API as JPA does not provide access to the underlying query string as soon as one has created a
+	 * {@link Query} instance of it.
 	 *
 	 * @param query
 	 * @return the query string representing the query or {@literal null} if resolving is not possible.
 	 */
 	@Nullable
-	String extractQueryString(Query query);
+	String extractQueryString(Object query);
+
+	/**
+	 * Reverse engineers the query native flag from a {@link Query} or native query as JPA does not provide access to the
+	 * underlying query string once a (named) query is constructed.
+	 *
+	 * @param query
+	 * @return {@literal true} if the query is a native one.
+	 * @since 4.0
+	 */
+	boolean isNativeQuery(Object query);
 
 	/**
 	 * Returns whether the extractor is able to extract the original query string from a given {@link Query}.
@@ -43,4 +55,5 @@ public interface QueryExtractor {
 	 * @return
 	 */
 	boolean canExtractQuery();
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/package-info.java
index 02605bbf3d..87977ed2ce 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/package-info.java
@@ -1,5 +1,5 @@
 /**
  * JPA provider-specific utilities.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.provider;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java
index c3249502e4..536ff5bca2 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java
@@ -15,23 +15,23 @@
  */
 package org.springframework.data.jpa.repository;
 
-import jakarta.persistence.criteria.CriteriaBuilder;
-import jakarta.persistence.criteria.CriteriaQuery;
-import jakarta.persistence.criteria.Root;
-
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Function;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.dao.InvalidDataAccessApiUsageException;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.DeleteSpecification;
+import org.springframework.data.jpa.domain.PredicateSpecification;
 import org.springframework.data.jpa.domain.Specification;
+import org.springframework.data.jpa.domain.UpdateSpecification;
 import org.springframework.data.repository.query.FluentQuery;
-import org.springframework.lang.Nullable;
 
 /**
  * Interface to allow execution of {@link Specification}s based on the JPA criteria API.
@@ -41,44 +41,70 @@
  * @author Diego Krupitza
  * @author Mark Paluch
  * @author Joshua Chen
+ * @see Specification
+ * @see org.springframework.data.jpa.domain.UpdateSpecification
+ * @see DeleteSpecification
+ * @see PredicateSpecification
  */
 public interface JpaSpecificationExecutor<T> {
 
+	/**
+	 * Returns a single entity matching the given {@link PredicateSpecification} or {@link Optional#empty()} if none
+	 * found.
+	 *
+	 * @param spec must not be {@literal null}.
+	 * @return never {@literal null}.
+	 * @throws org.springframework.dao.IncorrectResultSizeDataAccessException if more than one entity found.
+	 * @see Specification#unrestricted()
+	 */
+	default Optional<T> findOne(PredicateSpecification<T> spec) {
+		return findOne(Specification.where(spec));
+	}
+
 	/**
 	 * Returns a single entity matching the given {@link Specification} or {@link Optional#empty()} if none found.
 	 *
 	 * @param spec must not be {@literal null}.
 	 * @return never {@literal null}.
 	 * @throws org.springframework.dao.IncorrectResultSizeDataAccessException if more than one entity found.
+	 * @see Specification#unrestricted()
 	 */
 	Optional<T> findOne(Specification<T> spec);
 
+	/**
+	 * Returns all entities matching the given {@link PredicateSpecification}.
+	 *
+	 * @param spec must not be {@literal null}.
+	 * @return never {@literal null}.
+	 * @see Specification#unrestricted()
+	 */
+	default List<T> findAll(PredicateSpecification<T> spec) {
+		return findAll(Specification.where(spec));
+	}
+
 	/**
 	 * Returns all entities matching the given {@link Specification}.
-	 * <p>
-	 * If no {@link Specification} is given all entities matching {@code <T>} will be selected.
 	 *
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @return never {@literal null}.
+	 * @see Specification#unrestricted()
 	 */
-	List<T> findAll(@Nullable Specification<T> spec);
+	List<T> findAll(Specification<T> spec);
 
 	/**
 	 * Returns a {@link Page} of entities matching the given {@link Specification}.
-	 * <p>
-	 * If no {@link Specification} is given all entities matching {@code <T>} will be selected.
 	 *
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @param pageable must not be {@literal null}.
 	 * @return never {@literal null}.
+	 * @see Specification#unrestricted()
 	 */
-	Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable);
+	Page<T> findAll(Specification<T> spec, Pageable pageable);
 
 	/**
 	 * Returns a {@link Page} of entities matching the given {@link Specification}.
 	 * <p>
 	 * Supports counting the total number of entities matching the {@link Specification}.
-	 * <p>
 	 *
 	 * @param spec can be {@literal null}, if no {@link Specification} is given all entities matching {@code <T>} will be
 	 *          selected.
@@ -92,52 +118,113 @@ public interface JpaSpecificationExecutor<T> {
 
 	/**
 	 * Returns all entities matching the given {@link Specification} and {@link Sort}.
-	 * <p>
-	 * If no {@link Specification} is given all entities matching {@code <T>} will be selected.
 	 *
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @param sort must not be {@literal null}.
 	 * @return never {@literal null}.
+	 * @see Specification#unrestricted()
 	 */
-	List<T> findAll(@Nullable Specification<T> spec, Sort sort);
+	List<T> findAll(Specification<T> spec, Sort sort);
+
+	/**
+	 * Returns the number of instances that the given {@link PredicateSpecification} will return.
+	 *
+	 * @param spec the {@link PredicateSpecification} to count instances for, must not be {@literal null}.
+	 * @return the number of instances.
+	 * @see Specification#unrestricted()
+	 */
+	default long count(PredicateSpecification<T> spec) {
+		return count(Specification.where(spec));
+	}
 
 	/**
 	 * Returns the number of instances that the given {@link Specification} will return.
-	 * <p>
-	 * If no {@link Specification} is given all entities matching {@code <T>} will be counted.
 	 *
 	 * @param spec the {@link Specification} to count instances for, must not be {@literal null}.
 	 * @return the number of instances.
+	 * @see Specification#unrestricted()
 	 */
-	long count(@Nullable Specification<T> spec);
+	long count(Specification<T> spec);
+
+	/**
+	 * Checks whether the data store contains elements that match the given {@link PredicateSpecification}.
+	 *
+	 * @param spec the {@link PredicateSpecification} to use for the existence check, must not be {@literal null}.
+	 * @return {@code true} if the data store contains elements that match the given {@link PredicateSpecification}
+	 *         otherwise {@code false}.
+	 * @see Specification#unrestricted()
+	 */
+	default boolean exists(PredicateSpecification<T> spec) {
+		return exists(Specification.where(spec));
+	}
 
 	/**
 	 * Checks whether the data store contains elements that match the given {@link Specification}.
 	 *
-	 * @param spec the {@link Specification} to use for the existence check, ust not be {@literal null}.
+	 * @param spec the {@link Specification} to use for the existence check, must not be {@literal null}.
 	 * @return {@code true} if the data store contains elements that match the given {@link Specification} otherwise
 	 *         {@code false}.
+	 * @see Specification#unrestricted()
 	 */
 	boolean exists(Specification<T> spec);
 
 	/**
-	 * Deletes by the {@link Specification} and returns the number of rows deleted.
+	 * Updates entities by the {@link UpdateSpecification} and returns the number of rows updated.
+	 * <p>
+	 * This method uses {@link jakarta.persistence.criteria.CriteriaUpdate Criteria API bulk update} that maps directly to
+	 * database update operations. The persistence context is not synchronized with the result of the bulk update.
+	 *
+	 * @param spec the {@link UpdateSpecification} to use for the update query must not be {@literal null}.
+	 * @return the number of entities deleted.
+	 * @since 4.0
+	 */
+	long update(UpdateSpecification<T> spec);
+
+	/**
+	 * Deletes by the {@link PredicateSpecification} and returns the number of rows deleted.
 	 * <p>
 	 * This method uses {@link jakarta.persistence.criteria.CriteriaDelete Criteria API bulk delete} that maps directly to
 	 * database delete operations. The persistence context is not synchronized with the result of the bulk delete.
+	 *
+	 * @param spec the {@link PredicateSpecification} to use for the delete query, must not be {@literal null}.
+	 * @return the number of entities deleted.
+	 * @since 3.0
+	 * @see PredicateSpecification#unrestricted()
+	 */
+	default long delete(PredicateSpecification<T> spec) {
+		return delete(DeleteSpecification.where(spec));
+	}
+
+	/**
+	 * Deletes by the {@link UpdateSpecification} and returns the number of rows deleted.
 	 * <p>
-	 * Please note that {@link jakarta.persistence.criteria.CriteriaQuery} in,
-	 * {@link Specification#toPredicate(Root, CriteriaQuery, CriteriaBuilder)} will be {@literal null} because
-	 * {@link jakarta.persistence.criteria.CriteriaBuilder#createCriteriaDelete(Class)} does not implement
-	 * {@code CriteriaQuery}.
-	 * <p>
-	 * If no {@link Specification} is given all entities matching {@code <T>} will be deleted.
+	 * This method uses {@link jakarta.persistence.criteria.CriteriaDelete Criteria API bulk delete} that maps directly to
+	 * database delete operations. The persistence context is not synchronized with the result of the bulk delete.
 	 *
-	 * @param spec the {@link Specification} to use for the existence check, can not be {@literal null}.
+	 * @param spec the {@link UpdateSpecification} to use for the delete query must not be {@literal null}.
 	 * @return the number of entities deleted.
 	 * @since 3.0
+	 * @see DeleteSpecification#unrestricted()
 	 */
-	long delete(@Nullable Specification<T> spec);
+	long delete(DeleteSpecification<T> spec);
+
+	/**
+	 * Returns entities matching the given {@link Specification} applying the {@code queryFunction} that defines the query
+	 * and its result type.
+	 * <p>
+	 * The query object used with {@code queryFunction} is only valid inside the {@code findBy(…)} method call. This
+	 * requires the query function to return a query result and not the {@link FluentQuery} object itself to ensure the
+	 * query is executed inside the {@code findBy(…)} method.
+	 *
+	 * @param spec must not be null.
+	 * @param queryFunction the query function defining projection, sorting, and the result type
+	 * @return all entities matching the given Example.
+	 * @since 4.0
+	 */
+	default <S extends T, R> R findBy(PredicateSpecification<T> spec,
+			Function<? super SpecificationFluentQuery<S>, R> queryFunction) {
+		return findBy(Specification.where(spec), queryFunction);
+	}
 
 	/**
 	 * Returns entities matching the given {@link Specification} applying the {@code queryFunction} that defines the query
@@ -181,6 +268,21 @@ default SpecificationFluentQuery<T> project(String... properties) {
 		@Override
 		SpecificationFluentQuery<T> project(Collection<String> properties);
 
+		/**
+		 * Get a page of matching elements for {@link Pageable} and provide a custom {@link Specification count
+		 * specification}.
+		 *
+		 * @param pageable the pageable to request a paged result, can be {@link Pageable#unpaged()}, must not be
+		 *          {@literal null}. The given {@link Pageable} will override any previously specified {@link Sort sort} if
+		 *          the {@link Sort} object is not {@link Sort#isUnsorted()}. Any potentially specified {@link #limit(int)}
+		 *          will be overridden by {@link Pageable#getPageSize()}.
+		 * @param countSpec specification used to count results.
+		 * @return
+		 */
+		default Page<T> page(Pageable pageable, PredicateSpecification<?> countSpec) {
+			return page(pageable, Specification.where(countSpec));
+		}
+
 		/**
 		 * Get a page of matching elements for {@link Pageable} and provide a custom {@link Specification count
 		 * specification}.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java
index d10c90b68c..d12036c74b 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java
@@ -94,4 +94,5 @@
 	 * Name of the {@link jakarta.persistence.SqlResultSetMapping @SqlResultSetMapping(name)} to apply for this query.
 	 */
 	String sqlResultSetMapping() default "";
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java
index 12ff41bb71..4405d29bbb 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java
@@ -90,4 +90,5 @@
 	 * @since 3.0
 	 */
 	Class<? extends QueryRewriter> queryRewriter() default QueryRewriter.IdentityQueryRewriter.class;
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Temporal.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Temporal.java
index e7492ab305..1a5d941662 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Temporal.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Temporal.java
@@ -30,10 +30,12 @@
  *
  * @author Thomas Darimont
  * @author Oliver Gierke
+ * @deprecated since 4.0. Please use {@literal java.time} types instead.
  */
 @Retention(RetentionPolicy.RUNTIME)
 @Target(ElementType.PARAMETER)
 @Documented
+@Deprecated(since = "4.0", forRemoval = true)
 public @interface Temporal {
 
 	/**
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaJpaRepositoryTests.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotEntityGraph.java
similarity index 52%
rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaJpaRepositoryTests.java
rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotEntityGraph.java
index dd8a85fce2..388c041cb4 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaJpaRepositoryTests.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotEntityGraph.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2013-2025 the original author or authors.
+ * Copyright 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.
@@ -13,21 +13,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.springframework.data.jpa.repository.support;
+package org.springframework.data.jpa.repository.aot;
 
-import org.junit.jupiter.api.Disabled;
-import org.springframework.test.context.ContextConfiguration;
+import java.util.List;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.data.jpa.repository.EntityGraph;
 
 /**
- * Integration tests to execute {@link JpaRepositoryTests} against OpenJpa.
+ * AOT representation of an resolved entity graph. The graph can be either named or defined by attribute paths in case
+ * the named entity graph cannot be looked up.
  *
- * @author Oliver Gierke
+ * @author Mark Paluch
  */
-@ContextConfiguration("classpath:openjpa.xml")
-class OpenJpaJpaRepositoryTests extends JpaRepositoryTests {
-
-	@Override
-	@Disabled
-	void testCrudOperationsForCompoundKeyEntity() {
-	}
+record AotEntityGraph(@Nullable String name, EntityGraph.EntityGraphType type, List<String> attributePaths) {
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotMetamodel.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotMetamodel.java
new file mode 100644
index 0000000000..2b3f49bb28
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotMetamodel.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.metamodel.EmbeddableType;
+import jakarta.persistence.metamodel.EntityType;
+import jakarta.persistence.metamodel.ManagedType;
+import jakarta.persistence.metamodel.Metamodel;
+import jakarta.persistence.spi.ClassTransformer;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.hibernate.jpa.HibernatePersistenceProvider;
+import org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl;
+import org.hibernate.jpa.boot.internal.PersistenceUnitInfoDescriptor;
+import org.springframework.data.util.Lazy;
+import org.springframework.instrument.classloading.SimpleThrowawayClassLoader;
+import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo;
+
+/**
+ * @author Christoph Strobl
+ * @since 4.0
+ */
+class AotMetamodel implements Metamodel {
+
+	private final String persistenceUnit;
+	private final Set<Class<?>> managedTypes;
+	private final Lazy<EntityManagerFactory> entityManagerFactory = Lazy.of(this::init);
+	private final Lazy<Metamodel> metamodel = Lazy.of(() -> entityManagerFactory.get().getMetamodel());
+	private final Lazy<EntityManager> entityManager = Lazy.of(() -> entityManagerFactory.get().createEntityManager());
+
+	public AotMetamodel(Set<Class<?>> managedTypes) {
+		this("AotMetamodel", managedTypes);
+	}
+
+	private AotMetamodel(String persistenceUnit, Set<Class<?>> managedTypes) {
+		this.persistenceUnit = persistenceUnit;
+		this.managedTypes = managedTypes;
+	}
+
+	public static AotMetamodel hibernateModel(Class<?>... types) {
+		return new AotMetamodel(Set.of(types));
+	}
+
+	public static AotMetamodel hibernateModel(String persistenceUnit, Class<?>... types) {
+		return new AotMetamodel(persistenceUnit, Set.of(types));
+	}
+
+	public <X> EntityType<X> entity(Class<X> cls) {
+		return metamodel.get().entity(cls);
+	}
+
+	@Override
+	public EntityType<?> entity(String s) {
+		return metamodel.get().entity(s);
+	}
+
+	public <X> ManagedType<X> managedType(Class<X> cls) {
+		return metamodel.get().managedType(cls);
+	}
+
+	public <X> EmbeddableType<X> embeddable(Class<X> cls) {
+		return metamodel.get().embeddable(cls);
+	}
+
+	public Set<ManagedType<?>> getManagedTypes() {
+		return metamodel.get().getManagedTypes();
+	}
+
+	public Set<EntityType<?>> getEntities() {
+		return metamodel.get().getEntities();
+	}
+
+	public Set<EmbeddableType<?>> getEmbeddables() {
+		return metamodel.get().getEmbeddables();
+	}
+
+	public EntityManager entityManager() {
+		return entityManager.get();
+	}
+
+	public EntityManagerFactory getEntityManagerFactory() {
+		return entityManagerFactory.get();
+	}
+
+	EntityManagerFactory init() {
+
+		MutablePersistenceUnitInfo persistenceUnitInfo = new MutablePersistenceUnitInfo() {
+			@Override
+			public ClassLoader getNewTempClassLoader() {
+				return new SimpleThrowawayClassLoader(this.getClass().getClassLoader());
+			}
+
+			@Override
+			public void addTransformer(ClassTransformer classTransformer) {
+				// just ignore it
+			}
+		};
+
+		persistenceUnitInfo.setPersistenceUnitName(persistenceUnit);
+		this.managedTypes.stream().map(Class::getName).forEach(persistenceUnitInfo::addManagedClassName);
+
+		persistenceUnitInfo.setPersistenceProviderClassName(HibernatePersistenceProvider.class.getName());
+
+		return new EntityManagerFactoryBuilderImpl(new PersistenceUnitInfoDescriptor(persistenceUnitInfo) {
+			@Override
+			public List<String> getManagedClassNames() {
+				return persistenceUnitInfo.getManagedClassNames();
+			}
+		}, Map.of("hibernate.dialect", "org.hibernate.dialect.H2Dialect", "hibernate.boot.allow_jdbc_metadata_access",
+				"false")).build();
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQueries.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQueries.java
new file mode 100644
index 0000000000..51d639ea78
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQueries.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Function;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.data.jpa.repository.query.DeclaredQuery;
+import org.springframework.data.jpa.repository.query.QueryEnhancer;
+import org.springframework.data.jpa.repository.query.QueryEnhancerSelector;
+import org.springframework.data.repository.aot.generate.QueryMetadata;
+import org.springframework.util.StringUtils;
+
+/**
+ * Value object capturing queries used for repository query methods.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+record AotQueries(AotQuery result, AotQuery count) {
+
+	/**
+	 * Derive a count query from the given query.
+	 */
+	public static AotQueries from(StringAotQuery query, @Nullable String countProjection,
+			QueryEnhancerSelector selector) {
+		return from(query, StringAotQuery::getQuery, countProjection, selector);
+	}
+
+	/**
+	 * Derive a count query from the given query.
+	 */
+	public static <T extends AotQuery> AotQueries from(T query, Function<T, DeclaredQuery> queryMapper,
+			@Nullable String countProjection, QueryEnhancerSelector selector) {
+
+		DeclaredQuery underlyingQuery = queryMapper.apply(query);
+		QueryEnhancer queryEnhancer = selector.select(underlyingQuery).create(underlyingQuery);
+
+		String derivedCountQuery = queryEnhancer
+				.createCountQueryFor(StringUtils.hasText(countProjection) ? countProjection : null);
+
+		DeclaredQuery countQuery = underlyingQuery.rewrite(derivedCountQuery);
+		return new AotQueries(query, StringAotQuery.of(countQuery));
+	}
+
+	/**
+	 * Create new {@code AotQueries} for the given queries.
+	 */
+	public static AotQueries from(AotQuery result, AotQuery count) {
+		return new AotQueries(result, count);
+	}
+
+	public boolean isNative() {
+		return result().isNative();
+	}
+
+	public QueryMetadata toMetadata(boolean paging) {
+		return new AotQueryMetadata(paging);
+	}
+
+	/**
+	 * String and Named Query-based {@link QueryMetadata}.
+	 */
+	private class AotQueryMetadata implements QueryMetadata {
+
+		private final boolean paging;
+
+		AotQueryMetadata(boolean paging) {
+			this.paging = paging;
+		}
+
+		@Override
+		public Map<String, Object> serialize() {
+
+			Map<String, Object> serialized = new LinkedHashMap<>();
+
+			if (result() instanceof NamedAotQuery nq) {
+
+				serialized.put("name", nq.getName());
+				serialized.put("query", nq.getQueryString());
+			}
+
+			if (result() instanceof StringAotQuery sq) {
+				serialized.put("query", sq.getQueryString());
+			}
+
+			if (paging) {
+
+				if (count() instanceof NamedAotQuery nq) {
+
+					serialized.put("count-name", nq.getName());
+					serialized.put("count-query", nq.getQueryString());
+				}
+
+				if (count() instanceof StringAotQuery sq) {
+					serialized.put("count-query", sq.getQueryString());
+				}
+			}
+
+			return serialized;
+		}
+
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQuery.java
new file mode 100644
index 0000000000..6bf3a5186d
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQuery.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import java.util.List;
+
+import org.springframework.data.domain.Limit;
+import org.springframework.data.jpa.repository.query.ParameterBinding;
+
+/**
+ * AOT query value object along with its parameter bindings.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @since 4.0
+ */
+abstract class AotQuery {
+
+	private final List<ParameterBinding> parameterBindings;
+
+	AotQuery(List<ParameterBinding> parameterBindings) {
+		this.parameterBindings = parameterBindings;
+	}
+
+	/**
+	 * @return whether the query is a {@link jakarta.persistence.EntityManager#createNativeQuery native} one.
+	 */
+	public abstract boolean isNative();
+
+	/**
+	 * @return the list of parameter bindings.
+	 */
+	public List<ParameterBinding> getParameterBindings() {
+		return parameterBindings;
+	}
+
+	/**
+	 * @return the preliminary query limit.
+	 */
+	public Limit getLimit() {
+		return Limit.unlimited();
+	}
+
+	/**
+	 * @return whether the query is limited (e.g. {@code findTop10By}).
+	 */
+	public boolean isLimited() {
+		return getLimit().isLimited();
+	}
+
+	/**
+	 * @return whether the query a delete query.
+	 */
+	public boolean isDelete() {
+		return false;
+	}
+
+	/**
+	 * @return whether the query is an exists query.
+	 */
+	public boolean isExists() {
+		return false;
+	}
+
+	/**
+	 * @return {@literal true} if the query uses value expressions.
+	 */
+	public boolean hasExpression() {
+
+		for (ParameterBinding parameterBinding : parameterBindings) {
+			if (parameterBinding.getOrigin().isExpression()) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotRepositoryFragmentSupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotRepositoryFragmentSupport.java
new file mode 100644
index 0000000000..f5c9d16edb
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotRepositoryFragmentSupport.java
@@ -0,0 +1,175 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import jakarta.persistence.Tuple;
+
+import java.lang.reflect.Method;
+import java.util.Collection;
+import java.util.stream.Stream;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.core.CollectionFactory;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.expression.ValueEvaluationContextProvider;
+import org.springframework.data.expression.ValueExpression;
+import org.springframework.data.jpa.repository.query.DeclaredQuery;
+import org.springframework.data.jpa.repository.query.JpaParameters;
+import org.springframework.data.jpa.repository.query.QueryEnhancer;
+import org.springframework.data.jpa.repository.query.QueryEnhancerSelector;
+import org.springframework.data.jpa.util.TupleBackedMap;
+import org.springframework.data.projection.ProjectionFactory;
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport;
+import org.springframework.data.repository.query.ParametersSource;
+import org.springframework.data.repository.query.ReturnedType;
+import org.springframework.data.repository.query.ValueExpressionDelegate;
+import org.springframework.data.util.Lazy;
+import org.springframework.util.ConcurrentLruCache;
+
+/**
+ * Support class for JPA AOT repository fragments.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+public class AotRepositoryFragmentSupport {
+
+	private final RepositoryMetadata repositoryMetadata;
+
+	private final ValueExpressionDelegate valueExpressions;
+
+	private final ProjectionFactory projectionFactory;
+
+	private final Lazy<ConcurrentLruCache<DeclaredQuery, QueryEnhancer>> enhancers;
+
+	private final Lazy<ConcurrentLruCache<String, ValueExpression>> expressions;
+
+	private final Lazy<ConcurrentLruCache<Method, ValueEvaluationContextProvider>> contextProviders;
+
+	protected AotRepositoryFragmentSupport(QueryEnhancerSelector selector,
+			RepositoryFactoryBeanSupport.FragmentCreationContext context) {
+		this(selector, context.getRepositoryMetadata(), context.getValueExpressionDelegate(),
+				context.getProjectionFactory());
+	}
+
+	protected AotRepositoryFragmentSupport(QueryEnhancerSelector selector, RepositoryMetadata repositoryMetadata,
+			ValueExpressionDelegate valueExpressions, ProjectionFactory projectionFactory) {
+
+		this.repositoryMetadata = repositoryMetadata;
+		this.valueExpressions = valueExpressions;
+		this.projectionFactory = projectionFactory;
+		this.enhancers = Lazy.of(() -> new ConcurrentLruCache<>(32, query -> selector.select(query).create(query)));
+		this.expressions = Lazy.of(() -> new ConcurrentLruCache<>(32, valueExpressions::parse));
+		this.contextProviders = Lazy.of(() -> new ConcurrentLruCache<>(32, it -> valueExpressions
+				.createValueContextProvider(new JpaParameters(ParametersSource.of(repositoryMetadata, it)))));
+	}
+
+	/**
+	 * Rewrite a {@link DeclaredQuery} to apply {@link Sort} and {@link Class} projection.
+	 *
+	 * @param query
+	 * @param sort
+	 * @param returnedType
+	 * @return
+	 */
+	protected String rewriteQuery(DeclaredQuery query, Sort sort, Class<?> returnedType) {
+
+		QueryEnhancer queryStringEnhancer = this.enhancers.get().get(query);
+		return queryStringEnhancer.rewrite(new DefaultQueryRewriteInformation(sort,
+				ReturnedType.of(returnedType, repositoryMetadata.getDomainType(), projectionFactory)));
+	}
+
+	/**
+	 * Evaluate a Value Expression.
+	 *
+	 * @param method
+	 * @param expressionString
+	 * @param args
+	 * @return
+	 */
+	protected @Nullable Object evaluateExpression(Method method, String expressionString, Object... args) {
+
+		ValueExpression expression = this.expressions.get().get(expressionString);
+		ValueEvaluationContextProvider contextProvider = this.contextProviders.get().get(method);
+
+		return expression.evaluate(contextProvider.getEvaluationContext(args, expression.getExpressionDependencies()));
+	}
+
+	protected <T> @Nullable T convertOne(@Nullable Object result, boolean nativeQuery, Class<T> projection) {
+
+		if (result == null) {
+			return null;
+		}
+
+		if (projection.isInstance(result)) {
+			return projection.cast(result);
+		}
+
+		return projectionFactory.createProjection(projection,
+				result instanceof Tuple t ? new TupleBackedMap(nativeQuery ? TupleBackedMap.underscoreAware(t) : t) : result);
+	}
+
+	protected @Nullable Object convertMany(@Nullable Object result, boolean nativeQuery, Class<?> projection) {
+
+		if (result == null) {
+			return null;
+		}
+
+		if (projection.isInstance(result)) {
+			return result;
+		}
+
+		if (result instanceof Stream<?> stream) {
+			return stream.map(it -> convertOne(it, nativeQuery, projection));
+		}
+
+		if (result instanceof Slice<?> slice) {
+			return slice.map(it -> convertOne(it, nativeQuery, projection));
+		}
+
+		if (result instanceof Collection<?> collection) {
+
+			Collection<@Nullable Object> target = CollectionFactory.createCollection(collection.getClass(),
+					collection.size());
+			for (Object o : collection) {
+				target.add(convertOne(o, nativeQuery, projection));
+			}
+
+			return target;
+		}
+
+		throw new UnsupportedOperationException("Cannot create projection for %s".formatted(result));
+	}
+
+	private record DefaultQueryRewriteInformation(Sort sort,
+			ReturnedType returnedType) implements QueryEnhancer.QueryRewriteInformation {
+
+		@Override
+		public Sort getSort() {
+			return sort();
+		}
+
+		@Override
+		public ReturnedType getReturnedType() {
+			return returnedType();
+		}
+
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/EntityGraphLookup.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/EntityGraphLookup.java
new file mode 100644
index 0000000000..7e715f9e24
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/EntityGraphLookup.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityManagerFactory;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.query.JpaQueryMethod;
+import org.springframework.data.repository.core.RepositoryInformation;
+import org.springframework.data.repository.query.ReturnedType;
+import org.springframework.util.StringUtils;
+
+/**
+ * Factory for {@link AotEntityGraph}.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+class EntityGraphLookup {
+
+	private final EntityManagerFactory entityManagerFactory;
+
+	public EntityGraphLookup(EntityManagerFactory entityManagerFactory) {
+		this.entityManagerFactory = entityManagerFactory;
+	}
+
+	@SuppressWarnings("unchecked")
+	public @Nullable AotEntityGraph findEntityGraph(MergedAnnotation<EntityGraph> entityGraph,
+			RepositoryInformation information, ReturnedType returnedType, JpaQueryMethod queryMethod) {
+
+		if (!entityGraph.isPresent()) {
+			return null;
+		}
+
+		EntityGraph.EntityGraphType type = entityGraph.getEnum("type", EntityGraph.EntityGraphType.class);
+		String[] attributePaths = entityGraph.getStringArray("attributePaths");
+		Collection<String> entityGraphNames = getEntityGraphNames(entityGraph, information, queryMethod);
+		List<Class<?>> candidates = Arrays.asList(returnedType.getDomainType(), returnedType.getReturnedType(),
+				returnedType.getTypeToRead());
+
+		for (Class<?> candidate : candidates) {
+
+			Map<String, jakarta.persistence.EntityGraph<?>> namedEntityGraphs = entityManagerFactory
+					.getNamedEntityGraphs(Class.class.cast(candidate));
+
+			if (namedEntityGraphs.isEmpty()) {
+				continue;
+			}
+
+			for (String entityGraphName : entityGraphNames) {
+				if (namedEntityGraphs.containsKey(entityGraphName)) {
+					return new AotEntityGraph(entityGraphName, type, Collections.emptyList());
+				}
+			}
+		}
+
+		if (attributePaths.length > 0) {
+			return new AotEntityGraph(null, type, Arrays.asList(attributePaths));
+		}
+
+		return null;
+	}
+
+	private Set<String> getEntityGraphNames(MergedAnnotation<EntityGraph> entityGraph, RepositoryInformation information,
+			JpaQueryMethod queryMethod) {
+
+		Set<String> entityGraphNames = new LinkedHashSet<>();
+		String value = entityGraph.getString("value");
+
+		if (StringUtils.hasText(value)) {
+			entityGraphNames.add(value);
+		}
+		entityGraphNames.add(queryMethod.getNamedQueryName());
+		entityGraphNames.add(getFallbackEntityGraphName(information, queryMethod));
+		return entityGraphNames;
+	}
+
+	private String getFallbackEntityGraphName(RepositoryInformation information, JpaQueryMethod queryMethod) {
+
+		Class<?> domainType = information.getDomainType();
+		Entity entity = AnnotatedElementUtils.findMergedAnnotation(domainType, Entity.class);
+		String entityName = entity != null && StringUtils.hasText(entity.name()) ? entity.name()
+				: domainType.getSimpleName();
+
+		return entityName + "." + queryMethod.getName();
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java
new file mode 100644
index 0000000000..2cb7d332f4
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java
@@ -0,0 +1,706 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.Query;
+import jakarta.persistence.QueryHint;
+import jakarta.persistence.Tuple;
+
+import java.lang.reflect.Type;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.LongSupplier;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.core.DefaultParameterNameDiscoverer;
+import org.springframework.core.ParameterNameDiscoverer;
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.data.domain.SliceImpl;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.NativeQuery;
+import org.springframework.data.jpa.repository.QueryHints;
+import org.springframework.data.jpa.repository.QueryRewriter;
+import org.springframework.data.jpa.repository.query.DeclaredQuery;
+import org.springframework.data.jpa.repository.query.JpaQueryMethod;
+import org.springframework.data.jpa.repository.query.ParameterBinding;
+import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext;
+import org.springframework.data.repository.query.ReturnedType;
+import org.springframework.data.support.PageableExecutionUtils;
+import org.springframework.javapoet.CodeBlock;
+import org.springframework.javapoet.CodeBlock.Builder;
+import org.springframework.javapoet.TypeName;
+import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ObjectUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * Common code blocks for JPA AOT Fragment generation.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @since 4.0
+ */
+class JpaCodeBlocks {
+
+	/**
+	 * @return new {@link QueryBlockBuilder}.
+	 */
+	public static QueryBlockBuilder queryBuilder(AotQueryMethodGenerationContext context, JpaQueryMethod queryMethod) {
+		return new QueryBlockBuilder(context, queryMethod);
+	}
+
+	/**
+	 * @return new {@link QueryExecutionBlockBuilder}.
+	 */
+	static QueryExecutionBlockBuilder executionBuilder(AotQueryMethodGenerationContext context,
+			JpaQueryMethod queryMethod) {
+		return new QueryExecutionBlockBuilder(context, queryMethod);
+	}
+
+	/**
+	 * Builder for the actual query code block.
+	 */
+	static class QueryBlockBuilder {
+
+		private final AotQueryMethodGenerationContext context;
+		private final JpaQueryMethod queryMethod;
+		private String queryVariableName;
+		private @Nullable AotQueries queries;
+		private MergedAnnotation<QueryHints> queryHints = MergedAnnotation.missing();
+		private @Nullable AotEntityGraph entityGraph;
+		private @Nullable String sqlResultSetMapping;
+		private @Nullable Class<?> queryReturnType;
+		private @Nullable Class<?> queryRewriter = QueryRewriter.IdentityQueryRewriter.class;
+
+		private QueryBlockBuilder(AotQueryMethodGenerationContext context, JpaQueryMethod queryMethod) {
+			this.context = context;
+			this.queryMethod = queryMethod;
+			this.queryVariableName = context.localVariable("query");
+		}
+
+		public QueryBlockBuilder usingQueryVariableName(String queryVariableName) {
+
+			this.queryVariableName = context.localVariable(queryVariableName);
+			return this;
+		}
+
+		public QueryBlockBuilder filter(AotQueries query) {
+			this.queries = query;
+			return this;
+		}
+
+		public QueryBlockBuilder nativeQuery(MergedAnnotation<NativeQuery> nativeQuery) {
+
+			if (nativeQuery.isPresent()) {
+				this.sqlResultSetMapping = nativeQuery.getString("sqlResultSetMapping");
+			}
+			return this;
+		}
+
+		public QueryBlockBuilder queryHints(MergedAnnotation<QueryHints> queryHints) {
+
+			this.queryHints = queryHints;
+			return this;
+		}
+
+		public QueryBlockBuilder entityGraph(@Nullable AotEntityGraph entityGraph) {
+			this.entityGraph = entityGraph;
+			return this;
+		}
+
+		public QueryBlockBuilder queryReturnType(@Nullable Class<?> queryReturnType) {
+			this.queryReturnType = queryReturnType;
+			return this;
+		}
+
+		public QueryBlockBuilder queryRewriter(@Nullable Class<?> queryRewriter) {
+			this.queryRewriter = queryRewriter == null ? QueryRewriter.IdentityQueryRewriter.class : queryRewriter;
+			return this;
+		}
+
+		/**
+		 * Build the query block.
+		 *
+		 * @return
+		 */
+		public CodeBlock build() {
+
+			Assert.notNull(queries, "Queries must not be null");
+
+			boolean isProjecting = context.getReturnedType().isProjecting();
+			Class<?> actualReturnType = isProjecting ? context.getActualReturnType().toClass()
+					: context.getRepositoryInformation().getDomainType();
+
+			String dynamicReturnType = null;
+			if (queryMethod.getParameters().hasDynamicProjection()) {
+				dynamicReturnType = context.getParameterName(queryMethod.getParameters().getDynamicProjectionIndex());
+			}
+
+			CodeBlock.Builder builder = CodeBlock.builder();
+
+			String queryStringVariableName = null;
+			String queryRewriterName = null;
+
+			if (queries.result() instanceof StringAotQuery && queryRewriter != QueryRewriter.IdentityQueryRewriter.class) {
+
+				queryRewriterName = context.localVariable("queryRewriter");
+				builder.addStatement("$T $L = new $T()", queryRewriter, queryRewriterName, queryRewriter);
+			}
+
+			if (queries.result() instanceof StringAotQuery sq) {
+
+				queryStringVariableName = "%sString".formatted(queryVariableName);
+				builder.add(buildQueryString(sq, queryStringVariableName));
+			}
+
+			String countQueryStringNameVariableName = null;
+			String countQueryVariableName = context
+					.localVariable("count%s".formatted(StringUtils.capitalize(queryVariableName)));
+
+			if (queryMethod.isPageQuery() && queries.count() instanceof StringAotQuery sq) {
+
+				countQueryStringNameVariableName = context
+						.localVariable("count%sString".formatted(StringUtils.capitalize(queryVariableName)));
+				builder.add(buildQueryString(sq, countQueryStringNameVariableName));
+			}
+
+			String sortParameterName = context.getSortParameterName();
+			if (sortParameterName == null && context.getPageableParameterName() != null) {
+				sortParameterName = "%s.getSort()".formatted(context.getPageableParameterName());
+			}
+
+			if ((StringUtils.hasText(sortParameterName) || StringUtils.hasText(dynamicReturnType))
+					&& queries != null && queries.result() instanceof StringAotQuery
+					&& StringUtils.hasText(queryStringVariableName)) {
+				builder.add(applyRewrite(sortParameterName, dynamicReturnType, queryStringVariableName, actualReturnType));
+			}
+
+			if (queries.result().hasExpression() || queries.count().hasExpression()) {
+				builder.addStatement("class ExpressionMarker{}");
+			}
+
+			builder.add(createQuery(false, queryVariableName, queryStringVariableName, queryRewriterName, queries.result(),
+					this.sqlResultSetMapping, this.queryHints, this.entityGraph, this.queryReturnType));
+
+			builder.add(applyLimits(queries.result().isExists()));
+
+			if (queryMethod.isPageQuery()) {
+
+				builder.beginControlFlow("$T $L = () ->", LongSupplier.class, context.localVariable("countAll"));
+
+				boolean queryHints = this.queryHints.isPresent() && this.queryHints.getBoolean("forCounting");
+
+				builder.add(createQuery(true, countQueryVariableName, countQueryStringNameVariableName, queryRewriterName,
+						queries.count(), null,
+						queryHints ? this.queryHints : MergedAnnotation.missing(), null, Long.class));
+				builder.addStatement("return ($T) $L.getSingleResult()", Long.class, countQueryVariableName);
+
+				// end control flow does not work well with lambdas
+				builder.unindent();
+				builder.add("};\n");
+			}
+
+			return builder.build();
+		}
+
+		private CodeBlock buildQueryString(StringAotQuery sq, String queryStringVariableName) {
+
+			CodeBlock.Builder builder = CodeBlock.builder();
+			builder.addStatement("$T $L = $S", String.class, queryStringVariableName, sq.getQueryString());
+			return builder.build();
+		}
+
+		private CodeBlock applyRewrite(@Nullable String sort, @Nullable String dynamicReturnType, String queryString,
+				Class<?> actualReturnType) {
+
+			Builder builder = CodeBlock.builder();
+
+			boolean hasSort = StringUtils.hasText(sort);
+			if (hasSort) {
+				builder.beginControlFlow("if ($L.isSorted())", sort);
+			}
+
+			builder.addStatement("$T $L = $T.$L($L)", DeclaredQuery.class, context.localVariable("declaredQuery"),
+					DeclaredQuery.class,
+					queries != null && queries.isNative() ? "nativeQuery" : "jpqlQuery", queryString);
+
+			boolean hasDynamicReturnType = StringUtils.hasText(dynamicReturnType);
+
+			if (hasSort && hasDynamicReturnType) {
+				builder.addStatement("$L = rewriteQuery($L, $L, $L)", queryString, context.localVariable("declaredQuery"), sort,
+						dynamicReturnType);
+			} else if (hasSort) {
+				builder.addStatement("$L = rewriteQuery($L, $L, $T.class)", queryString, context.localVariable("declaredQuery"),
+						sort, actualReturnType);
+			} else if (hasDynamicReturnType) {
+				builder.addStatement("$L = rewriteQuery($L, $T.unsorted(), $L)", context.localVariable("declaredQuery"),
+						queryString, Sort.class,
+						dynamicReturnType);
+			}
+
+			if (hasSort) {
+				builder.endControlFlow();
+			}
+
+			return builder.build();
+		}
+
+		private CodeBlock applyLimits(boolean exists) {
+
+			Builder builder = CodeBlock.builder();
+
+			if (exists) {
+				builder.addStatement("$L.setMaxResults(1)", queryVariableName);
+
+				return builder.build();
+			}
+
+			String limit = context.getLimitParameterName();
+
+			if (StringUtils.hasText(limit)) {
+				builder.beginControlFlow("if ($L.isLimited())", limit);
+				builder.addStatement("$L.setMaxResults($L.max())", queryVariableName, limit);
+				builder.endControlFlow();
+			} else if (queries != null && queries.result().isLimited()) {
+				builder.addStatement("$L.setMaxResults($L)", queryVariableName, queries.result().getLimit().max());
+			}
+
+			String pageable = context.getPageableParameterName();
+
+			if (StringUtils.hasText(pageable)) {
+
+				builder.beginControlFlow("if ($L.isPaged())", pageable);
+				builder.addStatement("$L.setFirstResult(Long.valueOf($L.getOffset()).intValue())", queryVariableName, pageable);
+				if (queryMethod.isSliceQuery()) {
+					builder.addStatement("$L.setMaxResults($L.getPageSize() + 1)", queryVariableName, pageable);
+				} else {
+					builder.addStatement("$L.setMaxResults($L.getPageSize())", queryVariableName, pageable);
+				}
+				builder.endControlFlow();
+			}
+
+			return builder.build();
+		}
+
+		private CodeBlock createQuery(boolean count, String queryVariableName, @Nullable String queryStringNameVariableName,
+				@Nullable String queryRewriterName, AotQuery query, @Nullable String sqlResultSetMapping,
+				MergedAnnotation<QueryHints> queryHints,
+				@Nullable AotEntityGraph entityGraph, @Nullable Class<?> queryReturnType) {
+
+			Builder builder = CodeBlock.builder();
+
+			builder.add(doCreateQuery(count, queryVariableName, queryStringNameVariableName, queryRewriterName, query,
+					sqlResultSetMapping,
+					queryReturnType));
+
+			if (entityGraph != null) {
+				builder.add(applyEntityGraph(entityGraph, queryVariableName));
+			}
+
+			if (queryHints.isPresent()) {
+				builder.add(applyHints(queryVariableName, queryHints));
+				builder.add("\n");
+			}
+
+			for (ParameterBinding binding : query.getParameterBindings()) {
+
+				Object prepare = binding.prepare("s");
+				Object parameterIdentifier = getParameterName(binding.getIdentifier());
+				String valueFormat = parameterIdentifier instanceof CharSequence ? "$S" : "$L";
+
+				if (prepare instanceof String prepared && !prepared.equals("s")) {
+
+					String format = prepared.replaceAll("%", "%%").replace("s", "%s");
+					builder.addStatement("$L.setParameter(%s, $S.formatted($L))".formatted(valueFormat), queryVariableName,
+							parameterIdentifier, format, getParameter(binding.getOrigin()));
+				} else {
+					builder.addStatement("$L.setParameter(%s, $L)".formatted(valueFormat), queryVariableName, parameterIdentifier,
+							getParameter(binding.getOrigin()));
+				}
+			}
+
+			return builder.build();
+		}
+
+		private CodeBlock doCreateQuery(boolean count, String queryVariableName,
+				@Nullable String queryStringName, @Nullable String queryRewriterName, AotQuery query,
+				@Nullable String sqlResultSetMapping,
+				@Nullable Class<?> queryReturnType) {
+
+			ReturnedType returnedType = context.getReturnedType();
+			Builder builder = CodeBlock.builder();
+			String queryStringNameToUse = queryStringName;
+
+			if (query instanceof StringAotQuery sq) {
+
+				if (StringUtils.hasText(queryRewriterName)) {
+
+					queryStringNameToUse = queryStringName + "Rewritten";
+
+					if (StringUtils.hasText(context.getPageableParameterName())) {
+						builder.addStatement("$T $L = $L.rewrite($L, $L)", String.class, queryStringNameToUse, queryRewriterName,
+								queryStringName, context.getPageableParameterName());
+					} else if (StringUtils.hasText(context.getSortParameterName())) {
+						builder.addStatement("$T $L = $L.rewrite($L, $L)", String.class, queryStringNameToUse, queryRewriterName,
+								queryStringName, context.getSortParameterName());
+					} else {
+						builder.addStatement("$T $L = $L.rewrite($L, $T.unsorted())", String.class, queryStringNameToUse,
+								queryRewriterName, queryStringName, Sort.class);
+					}
+				}
+
+				if (StringUtils.hasText(sqlResultSetMapping)) {
+
+					builder.addStatement("$T $L = this.$L.createNativeQuery($L, $S)", Query.class, queryVariableName,
+							context.fieldNameOf(EntityManager.class), queryStringNameToUse, sqlResultSetMapping);
+
+					return builder.build();
+				}
+
+				if (query.isNative()) {
+
+					if (queryReturnType != null) {
+
+						builder.addStatement("$T $L = this.$L.createNativeQuery($L, $T.class)", Query.class, queryVariableName,
+								context.fieldNameOf(EntityManager.class), queryStringNameToUse, queryReturnType);
+					} else {
+						builder.addStatement("$T $L = this.$L.createNativeQuery($L)", Query.class, queryVariableName,
+								context.fieldNameOf(EntityManager.class), queryStringNameToUse);
+					}
+
+					return builder.build();
+				}
+
+				if (sq.hasConstructorExpressionOrDefaultProjection() && !count && returnedType.isProjecting()
+						&& returnedType.getReturnedType().isInterface()) {
+					builder.addStatement("$T $L = this.$L.createQuery($L)", Query.class, queryVariableName,
+							context.fieldNameOf(EntityManager.class), queryStringNameToUse);
+				} else {
+
+					String createQueryMethod = query.isNative() ? "createNativeQuery" : "createQuery";
+
+					if (!sq.hasConstructorExpressionOrDefaultProjection() && !count && returnedType.isProjecting()
+							&& returnedType.getReturnedType().isInterface()) {
+						builder.addStatement("$T $L = this.$L.$L($L, $T.class)", Query.class, queryVariableName,
+								context.fieldNameOf(EntityManager.class), createQueryMethod, queryStringNameToUse, Tuple.class);
+					} else {
+						builder.addStatement("$T $L = this.$L.$L($L)", Query.class, queryVariableName,
+								context.fieldNameOf(EntityManager.class), createQueryMethod, queryStringNameToUse);
+					}
+				}
+
+				return builder.build();
+			}
+
+			if (query instanceof NamedAotQuery nq) {
+
+				if (!count && returnedType.isProjecting() && returnedType.getReturnedType().isInterface()) {
+					builder.addStatement("$T $L = this.$L.createNamedQuery($S)", Query.class, queryVariableName,
+							context.fieldNameOf(EntityManager.class), nq.getName());
+					return builder.build();
+				} else if (queryReturnType != null) {
+
+					builder.addStatement("$T $L = this.$L.createNamedQuery($S, $T.class)", Query.class, queryVariableName,
+							context.fieldNameOf(EntityManager.class), nq.getName(), queryReturnType);
+
+					return builder.build();
+				}
+
+				builder.addStatement("$T $L = this.$L.createNamedQuery($S)", Query.class, queryVariableName,
+						context.fieldNameOf(EntityManager.class), nq.getName());
+
+				return builder.build();
+			}
+
+			throw new UnsupportedOperationException("Unsupported query type: " + query);
+		}
+
+		private Object getParameterName(ParameterBinding.BindingIdentifier identifier) {
+			return identifier.hasName() ? identifier.getName() : Integer.valueOf(identifier.getPosition());
+		}
+
+		private Object getParameter(ParameterBinding.ParameterOrigin origin) {
+
+			if (origin.isMethodArgument() && origin instanceof ParameterBinding.MethodInvocationArgument mia) {
+
+				if (mia.identifier().hasPosition()) {
+					return context.getRequiredBindableParameterName(mia.identifier().getPosition() - 1);
+				}
+
+				if (mia.identifier().hasName()) {
+					return context.getRequiredBindableParameterName(mia.identifier().getName());
+				}
+			}
+
+			if (origin.isExpression() && origin instanceof ParameterBinding.Expression expr) {
+
+				Builder builder = CodeBlock.builder();
+				ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
+				var parameterNames = discoverer.getParameterNames(context.getMethod());
+
+				String expressionString = expr.expression().getExpressionString();
+				// re-wrap expression
+				if (!expressionString.startsWith("$")) {
+					expressionString = "#{" + expressionString + "}";
+				}
+
+				builder.add("evaluateExpression(ExpressionMarker.class.getEnclosingMethod(), $S, $L)", expressionString,
+						StringUtils.arrayToCommaDelimitedString(parameterNames));
+
+				return builder.build();
+			}
+
+			throw new UnsupportedOperationException("Not supported yet");
+		}
+
+		private CodeBlock applyEntityGraph(AotEntityGraph entityGraph, String queryVariableName) {
+
+			CodeBlock.Builder builder = CodeBlock.builder();
+
+			if (StringUtils.hasText(entityGraph.name())) {
+
+				builder.addStatement("$T<?> $L = $L.getEntityGraph($S)", jakarta.persistence.EntityGraph.class,
+						context.localVariable("entityGraph"),
+						context.fieldNameOf(EntityManager.class), entityGraph.name());
+			} else {
+
+				builder.addStatement("$T<$T> $L = $L.createEntityGraph($T.class)",
+						jakarta.persistence.EntityGraph.class, context.getActualReturnType().getType(),
+						context.localVariable("entityGraph"),
+						context.fieldNameOf(EntityManager.class), context.getActualReturnType().getType());
+
+				for (String attributePath : entityGraph.attributePaths()) {
+
+					String[] pathComponents = StringUtils.delimitedListToStringArray(attributePath, ".");
+
+					StringBuilder chain = new StringBuilder(context.localVariable("entityGraph"));
+					for (int i = 0; i < pathComponents.length; i++) {
+
+						if (i < pathComponents.length - 1) {
+							chain.append(".addSubgraph($S)");
+						} else {
+							chain.append(".addAttributeNodes($S)");
+						}
+					}
+
+					builder.addStatement(chain.toString(), (Object[]) pathComponents);
+				}
+
+				builder.addStatement("$L.setHint($S, $L)", queryVariableName, entityGraph.type().getKey(),
+						context.localVariable("entityGraph"));
+			}
+
+			return builder.build();
+		}
+
+		private CodeBlock applyHints(String queryVariableName, MergedAnnotation<QueryHints> queryHints) {
+
+			Builder hintsBuilder = CodeBlock.builder();
+			MergedAnnotation<QueryHint>[] values = queryHints.getAnnotationArray("value", QueryHint.class);
+
+			for (MergedAnnotation<QueryHint> hint : values) {
+				hintsBuilder.addStatement("$L.setHint($S, $S)", queryVariableName, hint.getString("name"),
+						hint.getString("value"));
+			}
+
+			return hintsBuilder.build();
+		}
+
+	}
+
+	static class QueryExecutionBlockBuilder {
+
+		private final AotQueryMethodGenerationContext context;
+		private final JpaQueryMethod queryMethod;
+		private @Nullable AotQuery aotQuery;
+		private String queryVariableName;
+		private MergedAnnotation<Modifying> modifying = MergedAnnotation.missing();
+
+		private QueryExecutionBlockBuilder(AotQueryMethodGenerationContext context, JpaQueryMethod queryMethod) {
+
+			this.context = context;
+			this.queryMethod = queryMethod;
+			this.queryVariableName = context.localVariable("query");
+		}
+
+		public QueryExecutionBlockBuilder referencing(String queryVariableName) {
+
+			this.queryVariableName = context.localVariable(queryVariableName);
+			return this;
+		}
+
+		public QueryExecutionBlockBuilder query(AotQuery aotQuery) {
+
+			this.aotQuery = aotQuery;
+			return this;
+		}
+
+		public QueryExecutionBlockBuilder modifying(MergedAnnotation<Modifying> modifying) {
+
+			this.modifying = modifying;
+			return this;
+		}
+
+		public CodeBlock build() {
+
+			Builder builder = CodeBlock.builder();
+
+			boolean isProjecting = context.getActualReturnType() != null
+					&& !ObjectUtils.nullSafeEquals(TypeName.get(context.getRepositoryInformation().getDomainType()),
+							context.getActualReturnType());
+			Type actualReturnType = isProjecting ? context.getActualReturnType().getType()
+					: context.getRepositoryInformation().getDomainType();
+			builder.add("\n");
+
+			if (modifying.isPresent()) {
+
+				if (modifying.getBoolean("flushAutomatically")) {
+					builder.addStatement("this.$L.flush()", context.fieldNameOf(EntityManager.class));
+				}
+
+				Class<?> returnType = context.getMethod().getReturnType();
+
+				if (returnsModifying(returnType)) {
+					builder.addStatement("int $L = $L.executeUpdate()", context.localVariable("result"), queryVariableName);
+				} else {
+					builder.addStatement("$L.executeUpdate()", queryVariableName);
+				}
+
+				if (modifying.getBoolean("clearAutomatically")) {
+					builder.addStatement("this.$L.clear()", context.fieldNameOf(EntityManager.class));
+				}
+
+				if (returnType == int.class || returnType == long.class || returnType == Integer.class) {
+					builder.addStatement("return $L", context.localVariable("result"));
+				}
+
+				if (returnType == Long.class) {
+					builder.addStatement("return (long) $L", context.localVariable("result"));
+				}
+
+				return builder.build();
+			}
+
+			if (aotQuery != null && aotQuery.isDelete()) {
+
+				builder.addStatement("$T<$T> $L = $L.getResultList()", List.class, actualReturnType,
+						context.localVariable("resultList"), queryVariableName);
+				builder.addStatement("$L.forEach($L::remove)", context.localVariable("resultList"),
+						context.fieldNameOf(EntityManager.class));
+				if (!context.getReturnType().isAssignableFrom(List.class)) {
+					if (ClassUtils.isAssignable(Number.class, context.getMethod().getReturnType())) {
+						builder.addStatement("return $T.valueOf($L.size())", context.getMethod().getReturnType(),
+								context.localVariable("resultList"));
+					} else {
+						builder.addStatement("return $L.isEmpty() ? null : $L.iterator().next()",
+								context.localVariable("resultList"), context.localVariable("resultList"));
+					}
+				} else {
+					builder.addStatement("return $L", context.localVariable("resultList"));
+				}
+			} else if (aotQuery != null && aotQuery.isExists()) {
+				builder.addStatement("return !$L.getResultList().isEmpty()", queryVariableName);
+			} else if (aotQuery != null) {
+
+				if (context.getReturnedType().isProjecting()) {
+
+					TypeName queryResultType = TypeName.get(context.getActualReturnType().toClass());
+
+					if (queryMethod.isCollectionQuery()) {
+						builder.addStatement("return ($T) convertMany($L.getResultList(), $L, $T.class)",
+								context.getReturnTypeName(), queryVariableName, aotQuery.isNative(), queryResultType);
+					} else if (queryMethod.isStreamQuery()) {
+						builder.addStatement("return ($T) convertMany($L.getResultStream(), $L, $T.class)",
+								context.getReturnTypeName(), queryVariableName, aotQuery.isNative(), queryResultType);
+					} else if (queryMethod.isPageQuery()) {
+						builder.addStatement(
+								"return $T.getPage(($T<$T>) convertMany($L.getResultList(), $L, $T.class), $L, $L)",
+								PageableExecutionUtils.class, List.class, actualReturnType, queryVariableName, aotQuery.isNative(),
+								queryResultType, context.getPageableParameterName(), context.localVariable("countAll"));
+					} else if (queryMethod.isSliceQuery()) {
+						builder.addStatement("$T<$T> $L = ($T<$T>) convertMany($L.getResultList(), $L, $T.class)", List.class,
+								actualReturnType, context.localVariable("resultList"), List.class, actualReturnType, queryVariableName,
+								aotQuery.isNative(),
+								queryResultType);
+						builder.addStatement("boolean $L = $L.isPaged() && $L.size() > $L.getPageSize()",
+								context.localVariable("hasNext"), context.getPageableParameterName(),
+								context.localVariable("resultList"), context.getPageableParameterName());
+						builder.addStatement(
+								"return new $T<>($L ? $L.subList(0, $L.getPageSize()) : $L, $L, $L)", SliceImpl.class,
+								context.localVariable("hasNext"), context.localVariable("resultList"),
+								context.getPageableParameterName(), context.localVariable("resultList"),
+								context.getPageableParameterName(), context.localVariable("hasNext"));
+					} else {
+
+						if (Optional.class.isAssignableFrom(context.getReturnType().toClass())) {
+							builder.addStatement("return $T.ofNullable(($T) convertOne($L.getSingleResultOrNull(), $L, $T.class))",
+									Optional.class, actualReturnType, queryVariableName, aotQuery.isNative(), queryResultType);
+						} else {
+							builder.addStatement("return ($T) convertOne($L.getSingleResultOrNull(), $L, $T.class)",
+									context.getReturnTypeName(), queryVariableName, aotQuery.isNative(), queryResultType);
+						}
+					}
+
+				} else {
+
+					if (queryMethod.isCollectionQuery()) {
+						builder.addStatement("return ($T) $L.getResultList()", context.getReturnTypeName(), queryVariableName);
+					} else if (queryMethod.isStreamQuery()) {
+						builder.addStatement("return ($T) $L.getResultStream()", context.getReturnTypeName(), queryVariableName);
+					} else if (queryMethod.isPageQuery()) {
+						builder.addStatement("return $T.getPage(($T<$T>) $L.getResultList(), $L, $L)",
+								PageableExecutionUtils.class, List.class, actualReturnType, queryVariableName,
+								context.getPageableParameterName(), context.localVariable("countAll"));
+					} else if (queryMethod.isSliceQuery()) {
+						builder.addStatement("$T<$T> $L = $L.getResultList()", List.class, actualReturnType,
+								context.localVariable("resultList"), queryVariableName);
+						builder.addStatement("boolean $L = $L.isPaged() && $L.size() > $L.getPageSize()",
+								context.localVariable("hasNext"), context.getPageableParameterName(),
+								context.localVariable("resultList"), context.getPageableParameterName());
+						builder.addStatement(
+								"return new $T<>($L ? $L.subList(0, $L.getPageSize()) : $L, $L, $L)", SliceImpl.class,
+								context.localVariable("hasNext"), context.localVariable("resultList"),
+								context.getPageableParameterName(), context.localVariable("resultList"),
+								context.getPageableParameterName(), context.localVariable("hasNext"));
+					} else {
+
+						if (Optional.class.isAssignableFrom(context.getReturnType().toClass())) {
+							builder.addStatement("return $T.ofNullable(($T) $L.getSingleResultOrNull())", Optional.class,
+									actualReturnType, queryVariableName);
+						} else {
+							builder.addStatement("return ($T) $L.getSingleResultOrNull()", context.getReturnTypeName(),
+									queryVariableName);
+						}
+					}
+				}
+			}
+
+			return builder.build();
+		}
+
+		public static boolean returnsModifying(Class<?> returnType) {
+
+			return returnType == int.class || returnType == long.class || returnType == Integer.class
+					|| returnType == Long.class;
+		}
+
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java
new file mode 100644
index 0000000000..1dcb10809b
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java
@@ -0,0 +1,222 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.core.annotation.MergedAnnotations;
+import org.springframework.data.jpa.provider.PersistenceProvider;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.NativeQuery;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.jpa.repository.QueryHints;
+import org.springframework.data.jpa.repository.query.JpaParameters;
+import org.springframework.data.jpa.repository.query.JpaQueryMethod;
+import org.springframework.data.jpa.repository.query.Procedure;
+import org.springframework.data.jpa.repository.query.QueryEnhancerSelector;
+import org.springframework.data.repository.aot.generate.AotRepositoryClassBuilder;
+import org.springframework.data.repository.aot.generate.AotRepositoryConstructorBuilder;
+import org.springframework.data.repository.aot.generate.MethodContributor;
+import org.springframework.data.repository.aot.generate.QueryMetadata;
+import org.springframework.data.repository.aot.generate.RepositoryContributor;
+import org.springframework.data.repository.config.AotRepositoryContext;
+import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport;
+import org.springframework.data.repository.query.QueryMethod;
+import org.springframework.data.repository.query.ReturnedType;
+import org.springframework.data.util.TypeInformation;
+import org.springframework.javapoet.CodeBlock;
+import org.springframework.javapoet.TypeName;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * JPA-specific {@link RepositoryContributor} contributing an AOT repository fragment using the {@link EntityManager}
+ * directly to run queries.
+ * <p>
+ * The underlying {@link jakarta.persistence.metamodel.Metamodel} requires Hibernate to build metamodel information.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @since 4.0
+ */
+public class JpaRepositoryContributor extends RepositoryContributor {
+
+	private final PersistenceProvider persistenceProvider;
+	private final QueriesFactory queriesFactory;
+	private final EntityGraphLookup entityGraphLookup;
+
+	public JpaRepositoryContributor(AotRepositoryContext repositoryContext) {
+
+		super(repositoryContext);
+
+		AotMetamodel amm = new AotMetamodel(repositoryContext.getResolvedTypes().stream()
+				.filter(it -> !it.getName().startsWith("jakarta.persistence")).collect(Collectors.toSet()));
+
+		this.persistenceProvider = PersistenceProvider.fromEntityManagerFactory(amm.getEntityManagerFactory());
+		this.queriesFactory = new QueriesFactory(amm.getEntityManagerFactory(), amm);
+		this.entityGraphLookup = new EntityGraphLookup(amm.getEntityManagerFactory());
+	}
+
+	public JpaRepositoryContributor(AotRepositoryContext repositoryContext, EntityManagerFactory entityManagerFactory) {
+
+		super(repositoryContext);
+
+		this.persistenceProvider = PersistenceProvider.fromEntityManagerFactory(entityManagerFactory);
+		this.queriesFactory = new QueriesFactory(entityManagerFactory);
+		this.entityGraphLookup = new EntityGraphLookup(entityManagerFactory);
+	}
+
+	@Override
+	protected void customizeClass(AotRepositoryClassBuilder classBuilder) {
+		classBuilder.customize(builder -> builder.superclass(TypeName.get(AotRepositoryFragmentSupport.class)));
+	}
+
+	@Override
+	protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) {
+
+		// TODO: BeanFactoryQueryRewriterProvider if there is a method using QueryRewriters.
+
+		constructorBuilder.addParameter("entityManager", EntityManager.class);
+		constructorBuilder.addParameter("context", RepositoryFactoryBeanSupport.FragmentCreationContext.class);
+
+		// TODO: Pick up the configured QueryEnhancerSelector
+		constructorBuilder.customize(builder -> {
+			builder.addStatement("super($T.DEFAULT_SELECTOR, context)", QueryEnhancerSelector.class);
+		});
+	}
+
+	@Override
+	protected @Nullable MethodContributor<? extends QueryMethod> contributeQueryMethod(Method method) {
+
+		JpaQueryMethod queryMethod = new JpaQueryMethod(method, getRepositoryInformation(), getProjectionFactory(),
+				persistenceProvider);
+
+		// meh!
+		QueryEnhancerSelector selector = QueryEnhancerSelector.DEFAULT_SELECTOR;
+
+		// no stored procedures for now.
+		if (queryMethod.isProcedureQuery()) {
+
+			Procedure procedure = AnnotatedElementUtils.findMergedAnnotation(method, Procedure.class);
+
+			MethodContributor.QueryMethodMetadataContributorBuilder<JpaQueryMethod> builder = MethodContributor
+					.forQueryMethod(queryMethod);
+
+			if (procedure != null) {
+
+				if (StringUtils.hasText(procedure.name())) {
+					return builder.metadataOnly(new NamedStoredProcedureMetadata(procedure.name()));
+				}
+
+				if (StringUtils.hasText(procedure.procedureName())) {
+					return builder.metadataOnly(new StoredProcedureMetadata(procedure.procedureName()));
+				}
+
+				if (StringUtils.hasText(procedure.value())) {
+					return builder.metadataOnly(new StoredProcedureMetadata(procedure.value()));
+				}
+			}
+
+			// TODO: Better fallback.
+			return null;
+		}
+
+		ReturnedType returnedType = queryMethod.getResultProcessor().getReturnedType();
+		JpaParameters parameters = queryMethod.getParameters();
+
+		MergedAnnotation<Query> query = MergedAnnotations.from(method).get(Query.class);
+
+		AotQueries aotQueries = queriesFactory.createQueries(getRepositoryInformation(), query, selector, queryMethod,
+				returnedType);
+
+		// no KeysetScrolling for now.
+		if (parameters.hasScrollPositionParameter()) {
+			return MethodContributor.forQueryMethod(queryMethod)
+					.metadataOnly(aotQueries.toMetadata(queryMethod.isPageQuery()));
+		}
+
+		// no dynamic projections.
+		if (parameters.hasDynamicProjection()) {
+			return MethodContributor.forQueryMethod(queryMethod)
+					.metadataOnly(aotQueries.toMetadata(queryMethod.isPageQuery()));
+		}
+
+		if (queryMethod.isModifyingQuery()) {
+
+			TypeInformation<?> returnType = getRepositoryInformation().getReturnType(method);
+
+			boolean returnsCount = JpaCodeBlocks.QueryExecutionBlockBuilder.returnsModifying(returnType.getType());
+
+			boolean isVoid = ClassUtils.isVoidType(returnType.getType());
+
+			if (!returnsCount && !isVoid) {
+				return MethodContributor.forQueryMethod(queryMethod)
+						.metadataOnly(aotQueries.toMetadata(queryMethod.isPageQuery()));
+			}
+		}
+
+		return MethodContributor.forQueryMethod(queryMethod).withMetadata(aotQueries.toMetadata(queryMethod.isPageQuery()))
+				.contribute(context -> {
+
+					CodeBlock.Builder body = CodeBlock.builder();
+
+					MergedAnnotation<NativeQuery> nativeQuery = context.getAnnotation(NativeQuery.class);
+					MergedAnnotation<QueryHints> queryHints = context.getAnnotation(QueryHints.class);
+					MergedAnnotation<EntityGraph> entityGraph = context.getAnnotation(EntityGraph.class);
+					MergedAnnotation<Modifying> modifying = context.getAnnotation(Modifying.class);
+
+					AotEntityGraph aotEntityGraph = entityGraphLookup.findEntityGraph(entityGraph, getRepositoryInformation(),
+							returnedType, queryMethod);
+
+					body.add(JpaCodeBlocks.queryBuilder(context, queryMethod).filter(aotQueries)
+							.queryReturnType(QueriesFactory.getQueryReturnType(aotQueries.result(), returnedType, context))
+							.nativeQuery(nativeQuery).queryHints(queryHints).entityGraph(aotEntityGraph)
+							.queryRewriter(query.isPresent() ? query.getClass("queryRewriter") : null).build());
+
+					body.add(JpaCodeBlocks.executionBuilder(context, queryMethod).modifying(modifying).query(aotQueries.result())
+							.build());
+
+					return body.build();
+				});
+	}
+
+	record StoredProcedureMetadata(String procedure) implements QueryMetadata {
+
+		@Override
+		public Map<String, Object> serialize() {
+			return Map.of("procedure", procedure());
+		}
+	}
+
+	record NamedStoredProcedureMetadata(String procedureName) implements QueryMetadata {
+
+		@Override
+		public Map<String, Object> serialize() {
+			return Map.of("procedure-name", procedureName());
+		}
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRuntimeHints.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRuntimeHints.java
index 80b67fd896..3b00237d29 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRuntimeHints.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRuntimeHints.java
@@ -22,6 +22,8 @@
 import java.util.List;
 
 import org.springframework.aot.hint.ExecutableMode;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.aot.hint.MemberCategory;
 import org.springframework.aot.hint.RuntimeHints;
 import org.springframework.aot.hint.RuntimeHintsRegistrar;
@@ -36,7 +38,6 @@
 import org.springframework.data.jpa.repository.support.SimpleJpaRepository;
 import org.springframework.data.querydsl.QuerydslPredicateExecutor;
 import org.springframework.data.querydsl.QuerydslUtils;
-import org.springframework.lang.Nullable;
 import org.springframework.util.ClassUtils;
 
 /**
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/NamedAotQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/NamedAotQuery.java
new file mode 100644
index 0000000000..e3813ce137
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/NamedAotQuery.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import java.util.List;
+
+import org.springframework.data.jpa.repository.query.DeclaredQuery;
+import org.springframework.data.jpa.repository.query.ParameterBinding;
+import org.springframework.data.jpa.repository.query.PreprocessedQuery;
+
+/**
+ * Value object to describe a named AOT query.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+class NamedAotQuery extends AotQuery {
+
+	private final String name;
+	private final DeclaredQuery query;
+
+	private NamedAotQuery(String name, DeclaredQuery queryString, List<ParameterBinding> parameterBindings) {
+		super(parameterBindings);
+		this.name = name;
+		this.query = queryString;
+	}
+
+	/**
+	 * Creates a new {@code NamedAotQuery}.
+	 */
+	public static NamedAotQuery named(String namedQuery, DeclaredQuery queryString) {
+
+		PreprocessedQuery parsed = PreprocessedQuery.parse(queryString);
+		return new NamedAotQuery(namedQuery, queryString, parsed.getBindings());
+	}
+
+	public String getName() {
+		return name;
+	}
+
+	public DeclaredQuery getQuery() {
+		return query;
+	}
+
+	public String getQueryString() {
+		return getQuery().getQueryString();
+	}
+
+	@Override
+	public boolean isNative() {
+		return query.isNative();
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java
new file mode 100644
index 0000000000..05c49f1144
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.Tuple;
+import jakarta.persistence.TypedQueryReference;
+import jakarta.persistence.metamodel.Metamodel;
+
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.function.UnaryOperator;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.data.domain.Limit;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.provider.QueryExtractor;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.jpa.repository.query.*;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
+import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext;
+import org.springframework.data.repository.core.RepositoryInformation;
+import org.springframework.data.repository.query.ReturnedType;
+import org.springframework.data.repository.query.parser.PartTree;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+
+/**
+ * Factory for {@link AotQueries}.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+class QueriesFactory {
+
+	private final EntityManagerFactory entityManagerFactory;
+	private final Metamodel metamodel;
+
+	public QueriesFactory(EntityManagerFactory entityManagerFactory) {
+		this(entityManagerFactory, entityManagerFactory.getMetamodel());
+	}
+
+	public QueriesFactory(EntityManagerFactory entityManagerFactory, Metamodel metamodel) {
+		this.metamodel = metamodel;
+		this.entityManagerFactory = entityManagerFactory;
+	}
+
+	/**
+	 * Creates the {@link AotQueries} used within a specific {@link JpaQueryMethod}.
+	 *
+	 * @param context
+	 * @param query
+	 * @param selector
+	 * @param queryMethod
+	 * @param returnedType
+	 * @return
+	 */
+	public AotQueries createQueries(RepositoryInformation repositoryInformation, MergedAnnotation<Query> query,
+			QueryEnhancerSelector selector, JpaQueryMethod queryMethod, ReturnedType returnedType) {
+
+		if (query.isPresent() && StringUtils.hasText(query.getString("value"))) {
+			return buildStringQuery(repositoryInformation.getDomainType(), returnedType, selector, query,
+					queryMethod);
+		}
+
+		TypedQueryReference<?> namedQuery = getNamedQuery(returnedType, queryMethod.getNamedQueryName());
+		if (namedQuery != null) {
+			return buildNamedQuery(returnedType, selector, namedQuery, query, queryMethod);
+		}
+
+		return buildPartTreeQuery(returnedType, repositoryInformation, query, queryMethod);
+	}
+
+	private AotQueries buildStringQuery(Class<?> domainType, ReturnedType returnedType, QueryEnhancerSelector selector,
+			MergedAnnotation<Query> query, JpaQueryMethod queryMethod) {
+
+		UnaryOperator<String> operator = s -> s.replaceAll("#\\{#entityName}", domainType.getName());
+		boolean isNative = query.getBoolean("nativeQuery");
+		Function<String, StringAotQuery> queryFunction = isNative ? StringAotQuery::nativeQuery : StringAotQuery::jpqlQuery;
+		queryFunction = operator.andThen(queryFunction);
+
+		String queryString = query.getString("value");
+
+		StringAotQuery aotStringQuery = queryFunction.apply(queryString);
+		String countQuery = query.getString("countQuery");
+
+		EntityQuery entityQuery = EntityQuery.create(aotStringQuery.getQuery(), selector);
+		if (entityQuery.hasConstructorExpression() || entityQuery.isDefaultProjection()) {
+			aotStringQuery = aotStringQuery.withConstructorExpressionOrDefaultProjection();
+		}
+
+		if (returnedType.isProjecting() && returnedType.hasInputProperties()
+				&& !returnedType.getReturnedType().isInterface()) {
+
+			QueryProvider rewritten = entityQuery.rewrite(new QueryEnhancer.QueryRewriteInformation() {
+				@Override
+				public Sort getSort() {
+					return Sort.unsorted();
+				}
+
+				@Override
+				public ReturnedType getReturnedType() {
+					return returnedType;
+				}
+			});
+
+			aotStringQuery = aotStringQuery.rewrite(rewritten);
+		}
+
+		if (StringUtils.hasText(countQuery)) {
+			return AotQueries.from(aotStringQuery, queryFunction.apply(countQuery));
+		}
+
+		String namedCountQueryName = queryMethod.getNamedCountQueryName();
+		TypedQueryReference<?> namedCountQuery = getNamedQuery(returnedType, namedCountQueryName);
+		if (namedCountQuery != null) {
+			return AotQueries.from(aotStringQuery, buildNamedAotQuery(namedCountQuery, queryMethod, isNative));
+		}
+
+		String countProjection = query.getString("countProjection");
+		return AotQueries.from(aotStringQuery, countProjection, selector);
+	}
+
+	private AotQueries buildNamedQuery(ReturnedType returnedType, QueryEnhancerSelector selector,
+			TypedQueryReference<?> namedQuery, MergedAnnotation<Query> query, JpaQueryMethod queryMethod) {
+
+		NamedAotQuery aotQuery = buildNamedAotQuery(namedQuery, queryMethod,
+				query.isPresent() && query.getBoolean("nativeQuery"));
+
+		String countQuery = query.isPresent() ? query.getString("countQuery") : null;
+		if (StringUtils.hasText(countQuery)) {
+			return AotQueries.from(aotQuery,
+					aotQuery.isNative() ? StringAotQuery.nativeQuery(countQuery) : StringAotQuery.jpqlQuery(countQuery));
+		}
+
+		TypedQueryReference<?> namedCountQuery = getNamedQuery(returnedType, queryMethod.getNamedCountQueryName());
+
+		if (namedCountQuery != null) {
+			return AotQueries.from(aotQuery, buildNamedAotQuery(namedCountQuery, queryMethod, aotQuery.isNative()));
+		}
+
+		String countProjection = query.isPresent() ? query.getString("countProjection") : null;
+		return AotQueries.from(aotQuery, it -> {
+			return StringAotQuery.of(aotQuery.getQuery()).getQuery();
+		}, countProjection, selector);
+	}
+
+	private NamedAotQuery buildNamedAotQuery(TypedQueryReference<?> namedQuery, JpaQueryMethod queryMethod,
+			boolean isNative) {
+
+		QueryExtractor queryExtractor = queryMethod.getQueryExtractor();
+		String queryString = queryExtractor.extractQueryString(namedQuery);
+
+		if (!isNative) {
+			isNative = queryExtractor.isNativeQuery(namedQuery);
+		}
+
+		Assert.hasText(queryString, () -> "Cannot extract Query from named query [%s]".formatted(namedQuery.getName()));
+
+		return NamedAotQuery.named(namedQuery.getName(),
+				isNative ? DeclaredQuery.nativeQuery(queryString) : DeclaredQuery.jpqlQuery(queryString));
+	}
+
+	private @Nullable TypedQueryReference<?> getNamedQuery(ReturnedType returnedType, String queryName) {
+
+		List<Class<?>> candidates = Arrays.asList(Object.class, returnedType.getDomainType(),
+				returnedType.getReturnedType(), returnedType.getTypeToRead(), void.class, null, Long.class, Integer.class,
+				Long.TYPE, Integer.TYPE, Number.class);
+
+		for (Class<?> candidate : candidates) {
+
+			Map<String, ? extends TypedQueryReference<?>> namedQueries = entityManagerFactory.getNamedQueries(candidate);
+
+			if (namedQueries.containsKey(queryName)) {
+				return namedQueries.get(queryName);
+			}
+		}
+
+		return null;
+	}
+
+	private AotQueries buildPartTreeQuery(ReturnedType returnedType, RepositoryInformation repositoryInformation,
+			MergedAnnotation<Query> query, JpaQueryMethod queryMethod) {
+
+		PartTree partTree = new PartTree(queryMethod.getName(), repositoryInformation.getDomainType());
+		// TODO make configurable
+		JpqlQueryTemplates templates = JpqlQueryTemplates.UPPER;
+
+		AotQuery aotQuery = createQuery(partTree, returnedType, queryMethod.getParameters(), templates);
+
+		if (query.isPresent() && StringUtils.hasText(query.getString("countQuery"))) {
+			return AotQueries.from(aotQuery, StringAotQuery.jpqlQuery(query.getString("countQuery")));
+		}
+
+		TypedQueryReference<?> namedCountQuery = getNamedQuery(returnedType, queryMethod.getNamedCountQueryName());
+		if (namedCountQuery != null) {
+			return AotQueries.from(aotQuery, buildNamedAotQuery(namedCountQuery, queryMethod, false));
+		}
+
+		AotQuery partTreeCountQuery = createCountQuery(partTree, returnedType, queryMethod.getParameters(), templates);
+		return AotQueries.from(aotQuery, partTreeCountQuery);
+	}
+
+	private AotQuery createQuery(PartTree partTree, ReturnedType returnedType, JpaParameters parameters,
+			JpqlQueryTemplates templates) {
+
+		ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT,
+				templates);
+		JpaQueryCreator queryCreator = new JpaQueryCreator(partTree, returnedType, metadataProvider, templates, metamodel);
+
+		return StringAotQuery.jpqlQuery(queryCreator.createQuery(), metadataProvider.getBindings(),
+				partTree.getResultLimit(), partTree.isDelete(), partTree.isExistsProjection());
+	}
+
+	private AotQuery createCountQuery(PartTree partTree, ReturnedType returnedType, JpaParameters parameters,
+			JpqlQueryTemplates templates) {
+
+		ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT,
+				templates);
+		JpaQueryCreator queryCreator = new JpaCountQueryCreator(partTree, returnedType, metadataProvider, templates,
+				metamodel);
+
+		return StringAotQuery.jpqlQuery(queryCreator.createQuery(), metadataProvider.getBindings(), Limit.unlimited(),
+				false, false);
+	}
+
+	public static @Nullable Class<?> getQueryReturnType(AotQuery query, ReturnedType returnedType,
+			AotQueryMethodGenerationContext context) {
+
+		Method method = context.getMethod();
+		RepositoryInformation repositoryInformation = context.getRepositoryInformation();
+
+		Class<?> methodReturnType = repositoryInformation.getReturnedDomainClass(method);
+		boolean queryForEntity = repositoryInformation.getDomainType().isAssignableFrom(methodReturnType);
+
+		Class<?> result = queryForEntity ? returnedType.getDomainType() : null;
+
+		if (query instanceof StringAotQuery sq && sq.hasConstructorExpressionOrDefaultProjection()) {
+			return result;
+		}
+
+		if (returnedType.isProjecting()) {
+
+			if (returnedType.getReturnedType().isInterface()) {
+				return Tuple.class;
+			}
+
+			return returnedType.getReturnedType();
+		}
+
+		return result;
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/StringAotQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/StringAotQuery.java
new file mode 100644
index 0000000000..b30f0118c7
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/StringAotQuery.java
@@ -0,0 +1,216 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import java.util.List;
+
+import org.springframework.data.domain.Limit;
+import org.springframework.data.jpa.repository.query.DeclaredQuery;
+import org.springframework.data.jpa.repository.query.ParameterBinding;
+import org.springframework.data.jpa.repository.query.PreprocessedQuery;
+import org.springframework.data.jpa.repository.query.QueryProvider;
+
+/**
+ * An AOT query represented by a string.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+abstract class StringAotQuery extends AotQuery {
+
+	private StringAotQuery(List<ParameterBinding> parameterBindings) {
+		super(parameterBindings);
+	}
+
+	/**
+	 * Creates a new {@code StringAotQuery} from a {@link DeclaredQuery}. Parses the query into {@link PreprocessedQuery}.
+	 */
+	static StringAotQuery of(DeclaredQuery query) {
+
+		if (query instanceof PreprocessedQuery pq) {
+			return new DeclaredAotQuery(pq, false);
+		}
+
+		return new DeclaredAotQuery(PreprocessedQuery.parse(query), false);
+	}
+
+	/**
+	 * Creates a new {@code StringAotQuery} from a JPQL {@code queryString}. Parses the query into
+	 * {@link PreprocessedQuery}.
+	 */
+	static StringAotQuery jpqlQuery(String queryString) {
+		return of(DeclaredQuery.jpqlQuery(queryString));
+	}
+
+	/**
+	 * Creates a JPQL {@code StringAotQuery} using the given bindings and limit.
+	 */
+	public static StringAotQuery jpqlQuery(String queryString, List<ParameterBinding> bindings, Limit resultLimit,
+			boolean delete, boolean exists) {
+		return new DerivedAotQuery(queryString, bindings, resultLimit, delete, exists);
+	}
+
+	/**
+	 * Creates a new {@code StringAotQuery} from a native (SQL) {@code queryString}. Parses the query into
+	 * {@link PreprocessedQuery}.
+	 */
+	static StringAotQuery nativeQuery(String queryString) {
+		return of(DeclaredQuery.nativeQuery(queryString));
+	}
+
+	/**
+	 * @return the underlying declared query.
+	 */
+	public abstract DeclaredQuery getQuery();
+
+	public String getQueryString() {
+		return getQuery().getQueryString();
+	}
+
+	/**
+	 * @return {@literal true} if query is expected to return the declared method type directly; {@literal false} if the
+	 *         result requires projection post-processing. See also {@code NativeJpaQuery#getTypeToQueryFor}.
+	 */
+	public abstract boolean hasConstructorExpressionOrDefaultProjection();
+
+	/**
+	 * @return a new {@link StringAotQuery} using constructor expressions or containing the default (primary alias)
+	 *         projection.
+	 */
+	public abstract StringAotQuery withConstructorExpressionOrDefaultProjection();
+
+	@Override
+	public String toString() {
+		return getQueryString();
+	}
+
+	public abstract StringAotQuery rewrite(QueryProvider rewritten);
+
+	/**
+	 * @author Christoph Strobl
+	 * @author Mark Paluch
+	 */
+	private static class DeclaredAotQuery extends StringAotQuery {
+
+		private final PreprocessedQuery query;
+		private final boolean constructorExpressionOrDefaultProjection;
+
+		DeclaredAotQuery(PreprocessedQuery query, boolean constructorExpressionOrDefaultProjection) {
+			super(query.getBindings());
+			this.query = query;
+			this.constructorExpressionOrDefaultProjection = constructorExpressionOrDefaultProjection;
+		}
+
+		@Override
+		public PreprocessedQuery getQuery() {
+			return query;
+		}
+
+		@Override
+		public String getQueryString() {
+			return query.getQueryString();
+		}
+
+		@Override
+		public boolean isNative() {
+			return query.isNative();
+		}
+
+		@Override
+		public boolean hasConstructorExpressionOrDefaultProjection() {
+			return constructorExpressionOrDefaultProjection;
+		}
+
+		@Override
+		public StringAotQuery withConstructorExpressionOrDefaultProjection() {
+			return new DeclaredAotQuery(query, true);
+		}
+
+		@Override
+		public StringAotQuery rewrite(QueryProvider rewritten) {
+			return new DeclaredAotQuery(query.rewrite(rewritten.getQueryString()), constructorExpressionOrDefaultProjection);
+		}
+
+	}
+
+	/**
+	 * PartTree (derived) Query with a limit associated.
+	 *
+	 * @author Mark Paluch
+	 */
+	private static class DerivedAotQuery extends StringAotQuery {
+
+		private final String queryString;
+		private final Limit limit;
+		private final boolean delete;
+		private final boolean exists;
+
+		DerivedAotQuery(String queryString, List<ParameterBinding> parameterBindings, Limit limit, boolean delete,
+				boolean exists) {
+			super(parameterBindings);
+			this.queryString = queryString;
+			this.limit = limit;
+			this.delete = delete;
+			this.exists = exists;
+		}
+
+		@Override
+		public DeclaredQuery getQuery() {
+			return DeclaredQuery.jpqlQuery(queryString);
+		}
+
+		@Override
+		public String getQueryString() {
+			return queryString;
+		}
+
+		@Override
+		public boolean isNative() {
+			return false;
+		}
+
+		@Override
+		public Limit getLimit() {
+			return limit;
+		}
+
+		@Override
+		public boolean isDelete() {
+			return delete;
+		}
+
+		@Override
+		public boolean isExists() {
+			return exists;
+		}
+
+		@Override
+		public boolean hasConstructorExpressionOrDefaultProjection() {
+			return false;
+		}
+
+		@Override
+		public StringAotQuery withConstructorExpressionOrDefaultProjection() {
+			return this;
+		}
+
+		@Override
+		public StringAotQuery rewrite(QueryProvider rewritten) {
+			return new DerivedAotQuery(rewritten.getQueryString(), this.getParameterBindings(), getLimit(), delete, exists);
+		}
+
+	}
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/package-info.java
new file mode 100644
index 0000000000..a0fa7b10f2
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * Ahead-of-Time (AOT) generation for Spring Data JPA repositories.
+ */
+@org.jspecify.annotations.NullMarked
+package org.springframework.data.jpa.repository.aot;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/package-info.java
index c5fb3792d5..a186187b38 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/package-info.java
@@ -1,5 +1,5 @@
 /**
  * CDI support for Spring Data JPA Repositories.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.repository.cdi;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParser.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParser.java
index 8625119632..53ec098e86 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParser.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParser.java
@@ -17,6 +17,7 @@
 
 import static org.springframework.beans.factory.support.BeanDefinitionBuilder.*;
 
+import org.jspecify.annotations.Nullable;
 import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.beans.factory.parsing.BeanComponentDefinition;
 import org.springframework.beans.factory.support.AbstractBeanDefinition;
@@ -45,6 +46,7 @@ public class AuditingBeanDefinitionParser implements BeanDefinitionParser {
 	private final SpringConfiguredBeanDefinitionParser springConfiguredParser = new SpringConfiguredBeanDefinitionParser();
 
 	@Override
+	@SuppressWarnings("NullAway")
 	public BeanDefinition parse(Element element, ParserContext parser) {
 
 		springConfiguredParser.parse(element, parser);
@@ -90,7 +92,7 @@ private static class SpringConfiguredBeanDefinitionParser implements BeanDefinit
 		private static final String BEAN_CONFIGURER_ASPECT_CLASS_NAME = "org.springframework.beans.factory.aspectj.AnnotationBeanConfigurerAspect";
 
 		@Override
-		public BeanDefinition parse(Element element, ParserContext parserContext) {
+		public @Nullable BeanDefinition parse(Element element, ParserContext parserContext) {
 
 			if (!parserContext.getRegistry().containsBeanDefinition(BEAN_CONFIGURER_ASPECT_BEAN_NAME)) {
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaRepositories.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaRepositories.java
index 3ff333ea7c..22f32ed2de 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaRepositories.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaRepositories.java
@@ -28,6 +28,7 @@
 import org.springframework.context.annotation.ComponentScan.Filter;
 import org.springframework.context.annotation.Import;
 import org.springframework.context.annotation.Lazy;
+import org.springframework.data.jpa.repository.query.QueryEnhancerSelector;
 import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean;
 import org.springframework.data.repository.config.BootstrapMode;
 import org.springframework.data.repository.config.DefaultRepositoryBaseClass;
@@ -83,46 +84,39 @@
 	 * Returns the postfix to be used when looking up custom repository implementations. Defaults to {@literal Impl}. So
 	 * for a repository named {@code PersonRepository} the corresponding implementation class will be looked up scanning
 	 * for {@code PersonRepositoryImpl}.
-	 *
-	 * @return
 	 */
 	String repositoryImplementationPostfix() default "Impl";
 
 	/**
 	 * Configures the location of where to find the Spring Data named queries properties file. Will default to
 	 * {@code META-INF/jpa-named-queries.properties}.
-	 *
-	 * @return
 	 */
 	String namedQueriesLocation() default "";
 
 	/**
 	 * Returns the key of the {@link QueryLookupStrategy} to be used for lookup queries for query methods. Defaults to
 	 * {@link Key#CREATE_IF_NOT_FOUND}.
-	 *
-	 * @return
 	 */
 	Key queryLookupStrategy() default Key.CREATE_IF_NOT_FOUND;
 
 	/**
 	 * Returns the {@link FactoryBean} class to be used for each repository instance. Defaults to
 	 * {@link JpaRepositoryFactoryBean}.
-	 *
-	 * @return
 	 */
 	Class<?> repositoryFactoryBeanClass() default JpaRepositoryFactoryBean.class;
 
 	/**
 	 * Configure the repository base class to be used to create repository proxies for this particular configuration.
 	 *
-	 * @return
 	 * @since 1.9
 	 */
 	Class<?> repositoryBaseClass() default DefaultRepositoryBaseClass.class;
 
 	/**
 	 * Configure a specific {@link BeanNameGenerator} to be used when creating the repository beans.
-	 * @return the {@link BeanNameGenerator} to be used or the base {@link BeanNameGenerator} interface to indicate context default.
+	 *
+	 * @return the {@link BeanNameGenerator} to be used or the base {@link BeanNameGenerator} interface to indicate
+	 *         context default.
 	 * @since 3.4
 	 */
 	Class<? extends BeanNameGenerator> nameGenerator() default BeanNameGenerator.class;
@@ -132,22 +126,18 @@
 	/**
 	 * Configures the name of the {@link EntityManagerFactory} bean definition to be used to create repositories
 	 * discovered through this annotation. Defaults to {@code entityManagerFactory}.
-	 *
-	 * @return
 	 */
 	String entityManagerFactoryRef() default "entityManagerFactory";
 
 	/**
 	 * Configures the name of the {@link PlatformTransactionManager} bean definition to be used to create repositories
 	 * discovered through this annotation. Defaults to {@code transactionManager}.
-	 *
-	 * @return
 	 */
 	String transactionManagerRef() default "transactionManager";
 
 	/**
 	 * Configures whether nested repository-interfaces (e.g. defined as inner classes) should be discovered by the
-	 * repositories infrastructure.
+	 * repository infrastructure.
 	 */
 	boolean considerNestedRepositories() default false;
 
@@ -169,7 +159,6 @@
 	 * completed its bootstrap. {@link BootstrapMode#DEFERRED} is fundamentally the same as {@link BootstrapMode#LAZY},
 	 * but triggers repository initialization when the application context finishes its bootstrap.
 	 *
-	 * @return
 	 * @since 2.1
 	 */
 	BootstrapMode bootstrapMode() default BootstrapMode.DEFAULT;
@@ -181,4 +170,13 @@
 	 * @return a single character used for escaping.
 	 */
 	char escapeCharacter() default '\\';
+
+	/**
+	 * Configures the {@link QueryEnhancerSelector} to select a query enhancer for query introspection and transformation.
+	 *
+	 * @return a {@link QueryEnhancerSelector} class providing a no-args constructor.
+	 * @since 4.0
+	 */
+	Class<? extends QueryEnhancerSelector> queryEnhancerSelector() default QueryEnhancerSelector.DefaultQueryEnhancerSelector.class;
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaMetamodelMappingContextFactoryBean.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaMetamodelMappingContextFactoryBean.java
index 2bd8cd5ec8..9ccfa3f038 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaMetamodelMappingContextFactoryBean.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaMetamodelMappingContextFactoryBean.java
@@ -23,6 +23,8 @@
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.beans.BeansException;
 import org.springframework.beans.factory.BeanFactoryUtils;
 import org.springframework.beans.factory.FactoryBean;
@@ -32,7 +34,6 @@
 import org.springframework.context.ApplicationContextAware;
 import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext;
 import org.springframework.data.util.StreamUtils;
-import org.springframework.lang.Nullable;
 
 /**
  * {@link FactoryBean} to setup {@link JpaMetamodelMappingContext} instances from Spring configuration.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java
index 1bcf8073a8..ce3218593f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java
@@ -18,6 +18,7 @@
 import static org.springframework.data.jpa.repository.config.BeanDefinitionNames.*;
 
 import jakarta.persistence.Entity;
+import jakarta.persistence.EntityManagerFactory;
 import jakarta.persistence.MappedSuperclass;
 import jakarta.persistence.PersistenceContext;
 import jakarta.persistence.PersistenceUnit;
@@ -33,9 +34,12 @@
 import java.util.Optional;
 import java.util.Set;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.aot.generate.GenerationContext;
 import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor;
 import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.beans.factory.support.AbstractBeanDefinition;
 import org.springframework.beans.factory.support.BeanDefinitionBuilder;
 import org.springframework.beans.factory.support.BeanDefinitionRegistry;
@@ -45,18 +49,21 @@
 import org.springframework.core.io.ResourceLoader;
 import org.springframework.dao.DataAccessException;
 import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;
+import org.springframework.data.aot.AotContext;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.aot.JpaRepositoryContributor;
 import org.springframework.data.jpa.repository.support.DefaultJpaContext;
 import org.springframework.data.jpa.repository.support.EntityManagerBeanDefinitionRegistrarPostProcessor;
 import org.springframework.data.jpa.repository.support.JpaEvaluationContextExtension;
 import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean;
+import org.springframework.data.jpa.repository.support.SimpleJpaRepository;
+import org.springframework.data.repository.aot.generate.RepositoryContributor;
 import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource;
 import org.springframework.data.repository.config.AotRepositoryContext;
 import org.springframework.data.repository.config.RepositoryConfigurationExtensionSupport;
 import org.springframework.data.repository.config.RepositoryConfigurationSource;
 import org.springframework.data.repository.config.RepositoryRegistrationAotProcessor;
 import org.springframework.data.repository.config.XmlRepositoryConfigurationSource;
-import org.springframework.lang.Nullable;
 import org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor;
 import org.springframework.util.ClassUtils;
 import org.springframework.util.StringUtils;
@@ -91,6 +98,11 @@ public String getModuleName() {
 		return "JPA";
 	}
 
+	@Override
+	public String getRepositoryBaseClassName() {
+		return SimpleJpaRepository.class.getName();
+	}
+
 	@Override
 	public String getRepositoryFactoryBeanClassName() {
 		return JpaRepositoryFactoryBean.class.getName();
@@ -116,9 +128,16 @@ public void postProcess(BeanDefinitionBuilder builder, RepositoryConfigurationSo
 
 		Optional<String> transactionManagerRef = source.getAttribute("transactionManagerRef");
 		builder.addPropertyValue("transactionManager", transactionManagerRef.orElse(DEFAULT_TRANSACTION_MANAGER_BEAN_NAME));
-		builder.addPropertyReference("entityManager", entityManagerRefs.get(source));
+		if (entityManagerRefs.containsKey(source)) {
+			builder.addPropertyReference("entityManager", entityManagerRefs.get(source));
+		}
 		builder.addPropertyValue(ESCAPE_CHARACTER_PROPERTY, getEscapeCharacter(source).orElse('\\'));
 		builder.addPropertyReference("mappingContext", JPA_MAPPING_CONTEXT_BEAN_NAME);
+
+		if (source instanceof AnnotationRepositoryConfigurationSource) {
+			builder.addPropertyValue("queryEnhancerSelector",
+					source.getAttribute("queryEnhancerSelector", Class.class).orElse(null));
+		}
 	}
 
 	@Override
@@ -185,7 +204,6 @@ public void registerBeansForRoot(BeanDefinitionRegistry registry, RepositoryConf
 			contextDefinition.setAutowireMode(AbstractBeanDefinition.AUTOWIRE_CONSTRUCTOR);
 
 			return contextDefinition;
-
 		}, registry, JPA_CONTEXT_BEAN_NAME, source);
 
 		registerIfNotAlreadyRegistered(() -> new RootBeanDefinition(JPA_METAMODEL_CACHE_CLEANUP_CLASSNAME), registry,
@@ -203,7 +221,6 @@ public void registerBeansForRoot(BeanDefinitionRegistry registry, RepositoryConf
 			builder.addConstructorArgValue(value);
 
 			return builder.getBeanDefinition();
-
 		}, registry, JpaEvaluationContextExtension.class.getName(), source);
 	}
 
@@ -228,13 +245,13 @@ private String registerSharedEntityMangerIfNotAlreadyRegistered(BeanDefinitionRe
 	}
 
 	@Override
-	protected ClassLoader getConfigurationInspectionClassLoader(ResourceLoader loader) {
+	protected @Nullable ClassLoader getConfigurationInspectionClassLoader(ResourceLoader loader) {
 
 		ClassLoader classLoader = loader.getClassLoader();
 
 		return classLoader != null && LazyJvmAgent.isActive(loader.getClassLoader())
-				? new InspectionClassLoader(loader.getClassLoader())
-				: loader.getClassLoader();
+				? new InspectionClassLoader(classLoader)
+				: classLoader;
 	}
 
 	/**
@@ -308,8 +325,31 @@ static boolean isActive(@Nullable ClassLoader classLoader) {
 	 */
 	public static class JpaRepositoryRegistrationAotProcessor extends RepositoryRegistrationAotProcessor {
 
-		protected void contribute(AotRepositoryContext repositoryContext, GenerationContext generationContext) {
-			// don't register domain types nor annotations.
+		String GENERATED_REPOSITORIES_JPA_USE_ENTITY_MANAGER = "spring.aot.jpa.repositories.use-entitymanager";
+
+		protected @Nullable RepositoryContributor contribute(AotRepositoryContext repositoryContext,
+				GenerationContext generationContext) {
+
+			boolean enabled = Boolean.parseBoolean(
+					repositoryContext.getEnvironment().getProperty(AotContext.GENERATED_REPOSITORIES_ENABLED, "false"));
+			if (!enabled) {
+				return null;
+			}
+
+			boolean useEntityManager = Boolean.parseBoolean(
+					repositoryContext.getEnvironment().getProperty(GENERATED_REPOSITORIES_JPA_USE_ENTITY_MANAGER, "false"));
+
+			if (useEntityManager) {
+
+				ConfigurableListableBeanFactory beanFactory = repositoryContext.getBeanFactory();
+				EntityManagerFactory emf = beanFactory.getBeanProvider(EntityManagerFactory.class).getIfAvailable();
+
+				if (emf != null) {
+					return new JpaRepositoryContributor(repositoryContext, emf);
+				}
+			}
+
+			return new JpaRepositoryContributor(repositoryContext);
 		}
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/package-info.java
index 6e54455cfe..e2186fa63a 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Classes for JPA namespace configuration.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.repository.config;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/package-info.java
index 61ce846166..702e410e85 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Interfaces and annotations for JPA specific repositories.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.repository;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java
index fb9821c184..ef604e1f5b 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java
@@ -25,15 +25,12 @@
 
 import java.lang.reflect.Constructor;
 import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.HashMap;
 import java.util.List;
-import java.util.Map;
-import java.util.Set;
 import java.util.function.UnaryOperator;
 import java.util.stream.Collectors;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.beans.BeanUtils;
 import org.springframework.core.MethodParameter;
 import org.springframework.core.convert.converter.Converter;
@@ -48,14 +45,14 @@
 import org.springframework.data.jpa.repository.query.JpaQueryExecution.StreamExecution;
 import org.springframework.data.jpa.repository.support.QueryHints;
 import org.springframework.data.jpa.util.JpaMetamodel;
+import org.springframework.data.jpa.util.TupleBackedMap;
 import org.springframework.data.mapping.PreferredConstructor;
 import org.springframework.data.mapping.model.PreferredConstructorDiscoverer;
 import org.springframework.data.repository.query.RepositoryQuery;
 import org.springframework.data.repository.query.ResultProcessor;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.util.Lazy;
-import org.springframework.jdbc.support.JdbcUtils;
-import org.springframework.lang.Nullable;
+import org.springframework.lang.Contract;
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
 
@@ -109,7 +106,7 @@ public AbstractJpaQuery(JpaQueryMethod method, EntityManager em) {
 			} else if (method.isSliceQuery()) {
 				return new SlicedExecution();
 			} else if (method.isPageQuery()) {
-				return new PagedExecution();
+				return new PagedExecution(this.provider);
 			} else if (method.isModifyingQuery()) {
 				return null;
 			} else {
@@ -123,6 +120,15 @@ public JpaQueryMethod getQueryMethod() {
 		return method;
 	}
 
+	/**
+	 * Returns {@literal true} if the query has a dedicated count query associated with it or {@literal false} if the
+	 * count query shall be derived.
+	 *
+	 * @return {@literal true} if the query has a dedicated count query {@literal false} if the * count query is derived.
+	 * @since 3.5
+	 */
+	public abstract boolean hasDeclaredCountQuery();
+
 	/**
 	 * Returns the {@link EntityManager}.
 	 *
@@ -141,9 +147,8 @@ protected JpaMetamodel getMetamodel() {
 		return metamodel;
 	}
 
-	@Nullable
 	@Override
-	public Object execute(Object[] parameters) {
+	public @Nullable Object execute(Object[] parameters) {
 		return doExecute(getExecution(), parameters);
 	}
 
@@ -152,8 +157,7 @@ public Object execute(Object[] parameters) {
 	 * @param values
 	 * @return
 	 */
-	@Nullable
-	private Object doExecute(JpaQueryExecution execution, Object[] values) {
+	private @Nullable Object doExecute(JpaQueryExecution execution, Object[] values) {
 
 		JpaParametersParameterAccessor accessor = obtainParameterAccessor(values);
 		Object result = execution.execute(this, accessor);
@@ -193,6 +197,8 @@ protected JpaQueryExecution getExecution() {
 	 * @param query
 	 * @return
 	 */
+	@SuppressWarnings("NullAway")
+	@Contract("_, _ -> param1")
 	protected <T extends Query> T applyHints(T query, JpaQueryMethod method) {
 
 		List<QueryHint> hints = method.getHints();
@@ -242,8 +248,8 @@ private Query applyLockMode(Query query, JpaQueryMethod method) {
 		return lockModeType == null ? query : query.setLockMode(lockModeType);
 	}
 
-	protected ParameterBinder createBinder() {
-		return ParameterBinderFactory.createBinder(getQueryMethod().getParameters());
+	ParameterBinder createBinder() {
+		return ParameterBinderFactory.createBinder(getQueryMethod().getParameters(), false);
 	}
 
 	protected Query createQuery(JpaParametersParameterAccessor parameters) {
@@ -283,8 +289,7 @@ protected Query createCountQuery(JpaParametersParameterAccessor values) {
 	 * @return
 	 * @since 2.0.5
 	 */
-	@Nullable
-	protected Class<?> getTypeToRead(ReturnedType returnedType) {
+	protected @Nullable Class<?> getTypeToRead(ReturnedType returnedType) {
 
 		if (PersistenceProvider.ECLIPSELINK.equals(provider)) {
 			return null;
@@ -343,7 +348,7 @@ public TupleConverter(ReturnedType type, boolean nativeQuery) {
 			Assert.notNull(type, "Returned type must not be null");
 
 			this.type = type;
-			this.tupleWrapper = nativeQuery ? FallbackTupleWrapper::new : UnaryOperator.identity();
+			this.tupleWrapper = nativeQuery ? TupleBackedMap::underscoreAware : UnaryOperator.identity();
 			this.dtoProjection = type.isProjecting() && !type.getReturnedType().isInterface()
 					&& !type.getInputProperties().isEmpty();
 
@@ -467,181 +472,6 @@ private static boolean areAssignmentCompatible(Class<?> to, Class<?> from) {
 			return ClassUtils.isAssignable(to, from);
 		}
 
-		/**
-		 * A {@link Map} implementation which delegates all calls to a {@link Tuple}. Depending on the provided
-		 * {@link Tuple} implementation it might return the same value for various keys of which only one will appear in the
-		 * key/entry set.
-		 *
-		 * @author Jens Schauder
-		 */
-		private static class TupleBackedMap implements Map<String, Object> {
-
-			private static final String UNMODIFIABLE_MESSAGE = "A TupleBackedMap cannot be modified";
-
-			private final Tuple tuple;
-
-			TupleBackedMap(Tuple tuple) {
-				this.tuple = tuple;
-			}
-
-			@Override
-			public int size() {
-				return tuple.getElements().size();
-			}
-
-			@Override
-			public boolean isEmpty() {
-				return tuple.getElements().isEmpty();
-			}
-
-			/**
-			 * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code false}.
-			 * Otherwise this returns {@code true} even when the value from the backing {@code Tuple} is {@code null}.
-			 *
-			 * @param key the key for which to get the value from the map.
-			 * @return whether the key is an element of the backing tuple.
-			 */
-			@Override
-			public boolean containsKey(Object key) {
-
-				try {
-					tuple.get((String) key);
-					return true;
-				} catch (IllegalArgumentException e) {
-					return false;
-				}
-			}
-
-			@Override
-			public boolean containsValue(Object value) {
-				return Arrays.asList(tuple.toArray()).contains(value);
-			}
-
-			/**
-			 * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code null}.
-			 * Otherwise the value from the backing {@code Tuple} is returned, which also might be {@code null}.
-			 *
-			 * @param key the key for which to get the value from the map.
-			 * @return the value of the backing {@link Tuple} for that key or {@code null}.
-			 */
-			@Override
-			@Nullable
-			public Object get(Object key) {
-
-				if (!(key instanceof String)) {
-					return null;
-				}
-
-				try {
-					return tuple.get((String) key);
-				} catch (IllegalArgumentException e) {
-					return null;
-				}
-			}
-
-			@Override
-			public Object put(String key, Object value) {
-				throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE);
-			}
-
-			@Override
-			public Object remove(Object key) {
-				throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE);
-			}
-
-			@Override
-			public void putAll(Map<? extends String, ?> m) {
-				throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE);
-			}
-
-			@Override
-			public void clear() {
-				throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE);
-			}
-
-			@Override
-			public Set<String> keySet() {
-
-				return tuple.getElements().stream() //
-						.map(TupleElement::getAlias) //
-						.collect(Collectors.toSet());
-			}
-
-			@Override
-			public Collection<Object> values() {
-				return Arrays.asList(tuple.toArray());
-			}
-
-			@Override
-			public Set<Entry<String, Object>> entrySet() {
-
-				return tuple.getElements().stream() //
-						.map(e -> new HashMap.SimpleEntry<String, Object>(e.getAlias(), tuple.get(e))) //
-						.collect(Collectors.toSet());
-			}
-		}
 	}
 
-	private static class FallbackTupleWrapper implements Tuple {
-
-		private final Tuple delegate;
-		private final UnaryOperator<String> fallbackNameTransformer = JdbcUtils::convertPropertyNameToUnderscoreName;
-
-		FallbackTupleWrapper(Tuple delegate) {
-			this.delegate = delegate;
-		}
-
-		@Override
-		public <X> X get(TupleElement<X> tupleElement) {
-			return get(tupleElement.getAlias(), tupleElement.getJavaType());
-		}
-
-		@Override
-		public <X> X get(String s, Class<X> type) {
-			try {
-				return delegate.get(s, type);
-			} catch (IllegalArgumentException original) {
-				try {
-					return delegate.get(fallbackNameTransformer.apply(s), type);
-				} catch (IllegalArgumentException next) {
-					original.addSuppressed(next);
-					throw original;
-				}
-			}
-		}
-
-		@Override
-		public Object get(String s) {
-			try {
-				return delegate.get(s);
-			} catch (IllegalArgumentException original) {
-				try {
-					return delegate.get(fallbackNameTransformer.apply(s));
-				} catch (IllegalArgumentException next) {
-					original.addSuppressed(next);
-					throw original;
-				}
-			}
-		}
-
-		@Override
-		public <X> X get(int i, Class<X> aClass) {
-			return delegate.get(i, aClass);
-		}
-
-		@Override
-		public Object get(int i) {
-			return delegate.get(i);
-		}
-
-		@Override
-		public Object[] toArray() {
-			return delegate.toArray();
-		}
-
-		@Override
-		public List<TupleElement<?>> getElements() {
-			return delegate.getElements();
-		}
-	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java
index 91624e2631..c288d4a350 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java
@@ -18,17 +18,23 @@
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.Query;
 
+import java.util.List;
+import java.util.Map;
 import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.jspecify.annotations.Nullable;
 
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.expression.ValueEvaluationContextProvider;
 import org.springframework.data.jpa.repository.QueryRewriter;
+import org.springframework.data.mapping.PropertyPath;
+import org.springframework.data.mapping.PropertyReferenceException;
 import org.springframework.data.repository.query.ResultProcessor;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.repository.query.ValueExpressionDelegate;
 import org.springframework.data.util.Lazy;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.ConcurrentLruCache;
 import org.springframework.util.StringUtils;
@@ -48,14 +54,15 @@
  */
 abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
 
-	private final DeclaredQuery query;
-	private final Lazy<DeclaredQuery> countQuery;
+	private final EntityQuery query;
+	private final Map<Class<?>, Boolean> knownProjections = new ConcurrentHashMap<>();
+	private final Lazy<ParametrizedQuery> countQuery;
 	private final ValueExpressionDelegate valueExpressionDelegate;
-	private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache();
 	private final QueryRewriter queryRewriter;
 	private final QuerySortRewriter querySortRewriter;
 	private final Lazy<ParameterBinder> countParameterBinder;
 	private final ValueEvaluationContextProvider valueExpressionContextProvider;
+	private final boolean hasDeclaredCountQuery;
 
 	/**
 	 * Creates a new {@link AbstractStringBasedJpaQuery} from the given {@link JpaQueryMethod}, {@link EntityManager} and
@@ -64,40 +71,50 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery {
 	 * @param method must not be {@literal null}.
 	 * @param em must not be {@literal null}.
 	 * @param queryString must not be {@literal null}.
-	 * @param countQueryString must not be {@literal null}.
-	 * @param queryRewriter must not be {@literal null}.
-	 * @param valueExpressionDelegate must not be {@literal null}.
+	 * @param countQuery can be {@literal null} if not defined.
+	 * @param queryConfiguration must not be {@literal null}.
 	 */
-	public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, String queryString,
-			@Nullable String countQueryString, QueryRewriter queryRewriter, ValueExpressionDelegate valueExpressionDelegate) {
+	AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, String queryString,
+			@Nullable String countQueryString, JpaQueryConfiguration queryConfiguration) {
+		this(method, em, method.getDeclaredQuery(queryString),
+				countQueryString != null ? method.getDeclaredQuery(countQueryString) : null, queryConfiguration);
+	}
+
+	/**
+	 * Creates a new {@link AbstractStringBasedJpaQuery} from the given {@link JpaQueryMethod}, {@link EntityManager} and
+	 * query {@link String}.
+	 *
+	 * @param method must not be {@literal null}.
+	 * @param em must not be {@literal null}.
+	 * @param query must not be {@literal null}.
+	 * @param countQuery can be {@literal null}.
+	 * @param queryConfiguration must not be {@literal null}.
+	 */
+	public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, DeclaredQuery query,
+			@Nullable DeclaredQuery countQuery, JpaQueryConfiguration queryConfiguration) {
 
 		super(method, em);
 
-		Assert.hasText(queryString, "Query string must not be null or empty");
-		Assert.notNull(valueExpressionDelegate, "ValueExpressionDelegate must not be null");
-		Assert.notNull(queryRewriter, "QueryRewriter must not be null");
+		Assert.notNull(query, "Query must not be null");
+		Assert.notNull(queryConfiguration, "JpaQueryConfiguration must not be null");
 
-		this.valueExpressionDelegate = valueExpressionDelegate;
+		this.valueExpressionDelegate = queryConfiguration.getValueExpressionDelegate();
 		this.valueExpressionContextProvider = valueExpressionDelegate.createValueContextProvider(method.getParameters());
-		this.query = new ExpressionBasedStringQuery(queryString, method.getEntityInformation(), valueExpressionDelegate,
-				method.isNativeQuery());
 
-		this.countQuery = Lazy.of(() -> {
+		this.query = TemplatedQuery.create(query, method.getEntityInformation(), queryConfiguration);
+		this.hasDeclaredCountQuery = countQuery != null;
 
-			if (StringUtils.hasText(countQueryString)) {
+		this.countQuery = Lazy.of(() -> {
 
-				return new ExpressionBasedStringQuery(countQueryString, method.getEntityInformation(), valueExpressionDelegate,
-						method.isNativeQuery());
+			if (countQuery != null) {
+				return TemplatedQuery.create(countQuery, method.getEntityInformation(), queryConfiguration);
 			}
 
-			return query.deriveCountQuery(method.getCountQueryProjection());
-		});
-
-		this.countParameterBinder = Lazy.of(() -> {
-			return this.createBinder(this.countQuery.get());
+			return this.query.deriveCountQuery(method.getCountQueryProjection());
 		});
 
-		this.queryRewriter = queryRewriter;
+		this.countParameterBinder = Lazy.of(() -> this.createBinder(this.countQuery.get()));
+		this.queryRewriter = queryConfiguration.getQueryRewriter(method);
 
 		JpaParameters parameters = method.getParameters();
 
@@ -111,27 +128,110 @@ public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, Stri
 			}
 		}
 
-		Assert.isTrue(method.isNativeQuery() || !query.usesJdbcStyleParameters(),
+		Assert.isTrue(method.isNativeQuery() || !this.query.usesJdbcStyleParameters(),
 				"JDBC style parameters (?) are not supported for JPA queries");
 	}
 
+	@Override
+	public boolean hasDeclaredCountQuery() {
+		return hasDeclaredCountQuery;
+	}
+
 	@Override
 	public Query doCreateQuery(JpaParametersParameterAccessor accessor) {
 
 		Sort sort = accessor.getSort();
 		ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor);
-		ReturnedType returnedType = processor.getReturnedType();
-		String sortedQueryString = getSortedQueryString(sort, returnedType);
-		Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), returnedType);
-
-		QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(sortedQueryString, query);
+		ReturnedType returnedType = getReturnedType(processor);
+		QueryProvider sortedQuery = getSortedQuery(sort, returnedType);
+		Query query = createJpaQuery(sortedQuery, sort, accessor.getPageable(), returnedType);
 
 		// it is ok to reuse the binding contained in the ParameterBinder, although we create a new query String because the
 		// parameters in the query do not change.
-		return parameterBinder.get().bindAndPrepare(query, metadata, accessor);
+		return parameterBinder.get().bindAndPrepare(query, accessor);
 	}
 
-	String getSortedQueryString(Sort sort, ReturnedType returnedType) {
+	/**
+	 * Post-process {@link ReturnedType} to determine if the query is projecting by checking the projection and property
+	 * assignability.
+	 *
+	 * @param processor
+	 * @return
+	 */
+	private ReturnedType getReturnedType(ResultProcessor processor) {
+
+		ReturnedType returnedType = processor.getReturnedType();
+		Class<?> returnedJavaType = processor.getReturnedType().getReturnedType();
+
+		if (query.isDefaultProjection() || !returnedType.isProjecting() || returnedJavaType.isInterface()
+				|| query.isNative()) {
+			return returnedType;
+		}
+
+		Boolean known = knownProjections.get(returnedJavaType);
+
+		if (known != null && known) {
+			return returnedType;
+		}
+
+		if ((known != null && !known) || returnedJavaType.isArray()) {
+			if (known == null) {
+				knownProjections.put(returnedJavaType, false);
+			}
+			return new NonProjectingReturnedType(returnedType);
+		}
+
+		String projectionToUse = query.<@Nullable String> doWithEnhancer(queryEnhancer -> {
+
+			String alias = queryEnhancer.detectAlias();
+			String projection = queryEnhancer.getProjection();
+
+			// we can handle single-column and no function projections here only
+			if (StringUtils.hasText(projection) && (projection.indexOf(',') != -1 || projection.indexOf('(') != -1)) {
+				return null;
+			}
+
+			if (StringUtils.hasText(alias) && StringUtils.hasText(projection)) {
+				alias = alias.trim();
+				projection = projection.trim();
+				if (projection.startsWith(alias + ".")) {
+					projection = projection.substring(alias.length() + 1);
+				}
+			}
+
+			int space = projection.indexOf(' ');
+
+			if (space != -1) {
+				projection = projection.substring(0, space);
+			}
+
+			return projection;
+		});
+
+		if (StringUtils.hasText(projectionToUse)) {
+
+			Class<?> propertyType;
+
+			try {
+				PropertyPath from = PropertyPath.from(projectionToUse, getQueryMethod().getEntityInformation().getJavaType());
+				propertyType = from.getLeafType();
+			} catch (PropertyReferenceException ignored) {
+				propertyType = null;
+			}
+
+			if (propertyType == null
+					|| (returnedJavaType.isAssignableFrom(propertyType) || propertyType.isAssignableFrom(returnedJavaType))) {
+				knownProjections.put(returnedJavaType, false);
+				return new NonProjectingReturnedType(returnedType);
+			} else {
+				knownProjections.put(returnedJavaType, true);
+			}
+		}
+
+		return returnedType;
+	}
+
+	QueryProvider getSortedQuery(Sort sort, ReturnedType returnedType) {
 		return querySortRewriter.getSorted(query, sort, returnedType);
 	}
 
@@ -140,7 +240,7 @@ protected ParameterBinder createBinder() {
 		return createBinder(query);
 	}
 
-	protected ParameterBinder createBinder(DeclaredQuery query) {
+	protected ParameterBinder createBinder(ParametrizedQuery query) {
 		return ParameterBinderFactory.createQueryAwareBinder(getQueryMethod().getParameters(), query,
 				valueExpressionDelegate, valueExpressionContextProvider);
 	}
@@ -157,9 +257,8 @@ protected Query doCreateCountQuery(JpaParametersParameterAccessor accessor) {
 				? em.createNativeQuery(queryStringToUse) //
 				: em.createQuery(queryStringToUse, Long.class);
 
-		QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(queryString, query);
-
-		countParameterBinder.get().bind(metadata.withQuery(query), accessor, QueryParameterSetter.ErrorHandling.LENIENT);
+		countParameterBinder.get().bind(new QueryParameterSetter.BindableQuery(query), accessor,
+				QueryParameterSetter.ErrorHandling.LENIENT);
 
 		return query;
 	}
@@ -167,14 +266,14 @@ protected Query doCreateCountQuery(JpaParametersParameterAccessor accessor) {
 	/**
 	 * @return the query
 	 */
-	public DeclaredQuery getQuery() {
+	public EntityQuery getQuery() {
 		return query;
 	}
 
 	/**
 	 * @return the countQuery
 	 */
-	public DeclaredQuery getCountQuery() {
+	public ParametrizedQuery getCountQuery() {
 		return countQuery.get();
 	}
 
@@ -182,11 +281,11 @@ public DeclaredQuery getCountQuery() {
 	 * Creates an appropriate JPA query from an {@link EntityManager} according to the current {@link AbstractJpaQuery}
 	 * type.
 	 */
-	protected Query createJpaQuery(String queryString, Sort sort, @Nullable Pageable pageable,
+	protected Query createJpaQuery(QueryProvider query, Sort sort, @Nullable Pageable pageable,
 			ReturnedType returnedType) {
 
 		EntityManager em = getEntityManager();
-		String queryToUse = potentiallyRewriteQuery(queryString, sort, pageable);
+		String queryToUse = potentiallyRewriteQuery(query.getQueryString(), sort, pageable);
 
 		if (this.query.hasConstructorExpression() || this.query.isDefaultProjection()) {
 			return em.createQuery(queryToUse);
@@ -215,9 +314,8 @@ protected String potentiallyRewriteQuery(String originalQuery, Sort sort, @Nulla
 				: queryRewriter.rewrite(originalQuery, sort);
 	}
 
-	String applySorting(CachableQuery cachableQuery) {
-
-		return QueryEnhancerFactory.forQuery(cachableQuery.getDeclaredQuery())
+	QueryProvider applySorting(CachableQuery cachableQuery) {
+		return cachableQuery.getDeclaredQuery()
 				.rewrite(new DefaultQueryRewriteInformation(cachableQuery.getSort(), cachableQuery.getReturnedType()));
 	}
 
@@ -225,7 +323,7 @@ String applySorting(CachableQuery cachableQuery) {
 	 * Query Sort Rewriter interface.
 	 */
 	interface QuerySortRewriter {
-		String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType);
+		QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType);
 	}
 
 	/**
@@ -235,29 +333,28 @@ enum SimpleQuerySortRewriter implements QuerySortRewriter {
 
 		INSTANCE;
 
-		public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType) {
-
-			return QueryEnhancerFactory.forQuery(query).rewrite(new DefaultQueryRewriteInformation(sort, returnedType));
+		public QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType) {
+			return query.rewrite(new DefaultQueryRewriteInformation(sort, returnedType));
 		}
 	}
 
 	static class UnsortedCachingQuerySortRewriter implements QuerySortRewriter {
 
-		private volatile String cachedQueryString;
+		private volatile @Nullable QueryProvider cachedQuery;
 
-		public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType) {
+		public QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType) {
 
 			if (sort.isSorted()) {
 				throw new UnsupportedOperationException("NoOpQueryCache does not support sorting");
 			}
 
-			String cachedQueryString = this.cachedQueryString;
-			if (cachedQueryString == null) {
-				this.cachedQueryString = cachedQueryString = QueryEnhancerFactory.forQuery(query)
+			QueryProvider cachedQuery = this.cachedQuery;
+			if (cachedQuery == null) {
+				this.cachedQuery = cachedQuery = query
 						.rewrite(new DefaultQueryRewriteInformation(sort, returnedType));
 			}
 
-			return cachedQueryString;
+			return cachedQuery;
 		}
 	}
 
@@ -266,22 +363,22 @@ public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedTyp
 	 */
 	class CachingQuerySortRewriter implements QuerySortRewriter {
 
-		private final ConcurrentLruCache<CachableQuery, String> queryCache = new ConcurrentLruCache<>(16,
+		private final ConcurrentLruCache<CachableQuery, QueryProvider> queryCache = new ConcurrentLruCache<>(16,
 				AbstractStringBasedJpaQuery.this::applySorting);
 
-		private volatile String cachedQueryString;
+		private volatile @Nullable QueryProvider cachedQuery;
 
 		@Override
-		public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType) {
+		public QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType) {
 
 			if (sort.isUnsorted()) {
 
-				String cachedQueryString = this.cachedQueryString;
-				if (cachedQueryString == null) {
-					this.cachedQueryString = cachedQueryString = queryCache.get(new CachableQuery(query, sort, returnedType));
+				QueryProvider cachedQuery = this.cachedQuery;
+				if (cachedQuery == null) {
+					this.cachedQuery = cachedQuery = queryCache.get(new CachableQuery(query, sort, returnedType));
 				}
 
-				return cachedQueryString;
+				return cachedQuery;
 			}
 
 			return queryCache.get(new CachableQuery(query, sort, returnedType));
@@ -297,21 +394,21 @@ public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedTyp
 	 */
 	static class CachableQuery {
 
-		private final DeclaredQuery declaredQuery;
+		private final EntityQuery query;
 		private final String queryString;
 		private final Sort sort;
 		private final ReturnedType returnedType;
 
-		CachableQuery(DeclaredQuery query, Sort sort, ReturnedType returnedType) {
+		CachableQuery(EntityQuery query, Sort sort, ReturnedType returnedType) {
 
-			this.declaredQuery = query;
+			this.query = query;
 			this.queryString = query.getQueryString();
 			this.sort = sort;
 			this.returnedType = returnedType;
 		}
 
-		DeclaredQuery getDeclaredQuery() {
-			return declaredQuery;
+		EntityQuery getDeclaredQuery() {
+			return query;
 		}
 
 		Sort getSort() {
@@ -348,4 +445,46 @@ public int hashCode() {
 			return result;
 		}
 	}
+
+	/**
+	 * Non-projecting {@link ReturnedType} wrapper that delegates to the original {@link ReturnedType} but always returns
+	 * {@code false} for {@link #isProjecting()}. This type is to indicate that this query is not projecting, even if the
+	 * original {@link ReturnedType} was because we e.g. select a nested property and do not want DTO constructor
+	 * expression rewriting to kick in.
+	 */
+	private static class NonProjectingReturnedType extends ReturnedType {
+
+		private final ReturnedType delegate;
+
+		NonProjectingReturnedType(ReturnedType delegate) {
+			super(delegate.getDomainType());
+			this.delegate = delegate;
+		}
+
+		@Override
+		public boolean isProjecting() {
+			return false;
+		}
+
+		@Override
+		public Class<?> getReturnedType() {
+			return delegate.getReturnedType();
+		}
+
+		@Override
+		public boolean needsCustomConstruction() {
+			return false;
+		}
+
+		@Override
+		@Nullable
+		public Class<?> getTypeToRead() {
+			return delegate.getTypeToRead();
+		}
+
+		@Override
+		public List<String> getInputProperties() {
+			return delegate.getInputProperties();
+		}
+	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarException.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarException.java
index ab3b51b7b3..e731d9f3bc 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarException.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarException.java
@@ -15,8 +15,9 @@
  */
 package org.springframework.data.jpa.repository.query;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.dao.InvalidDataAccessResourceUsageException;
-import org.springframework.lang.Nullable;
 
 /**
  * An exception thrown if the JPQL query is invalid.
@@ -29,12 +30,12 @@ public class BadJpqlGrammarException extends InvalidDataAccessResourceUsageExcep
 
 	private final String jpql;
 
-	public BadJpqlGrammarException(String message, String jpql, @Nullable Throwable cause) {
+	public BadJpqlGrammarException(@Nullable String message, String jpql, @Nullable Throwable cause) {
 		this(message, jpql, "JPQL", cause);
 	}
 
-	BadJpqlGrammarException(String message, String grammar, String jpql, @Nullable Throwable cause) {
-		super(message + "; Bad " + grammar + " grammar [" + jpql + "]", cause);
+	BadJpqlGrammarException(@Nullable String message, String grammar, String jpql, @Nullable Throwable cause) {
+		super("%sBad %s grammar [%s]".formatted(message != null ? message + "; " : "", grammar, jpql), cause);
 		this.jpql = jpql;
 	}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQueries.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQueries.java
new file mode 100644
index 0000000000..2f6db9c5f7
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQueries.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.query;
+
+import org.springframework.util.ObjectUtils;
+
+/**
+ * Utility class encapsulating {@code DeclaredQuery} implementations.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @since 4.0
+ */
+class DeclaredQueries {
+
+	static final class JpqlQuery implements DeclaredQuery {
+
+		private final String jpql;
+
+		JpqlQuery(String jpql) {
+			this.jpql = jpql;
+		}
+
+		@Override
+		public boolean isNative() {
+			return false;
+		}
+
+		@Override
+		public String getQueryString() {
+			return jpql;
+		}
+
+		@Override
+		public boolean equals(Object o) {
+			if (!(o instanceof JpqlQuery jpqlQuery)) {
+				return false;
+			}
+			return ObjectUtils.nullSafeEquals(jpql, jpqlQuery.jpql);
+		}
+
+		@Override
+		public int hashCode() {
+			return ObjectUtils.nullSafeHashCode(jpql);
+		}
+
+		@Override
+		public String toString() {
+			return "JPQL[" + jpql + "]";
+		}
+
+	}
+
+	static final class NativeQuery implements DeclaredQuery {
+
+		private final String sql;
+
+		NativeQuery(String sql) {
+			this.sql = sql;
+		}
+
+		@Override
+		public boolean isNative() {
+			return true;
+		}
+
+		@Override
+		public String getQueryString() {
+			return sql;
+		}
+
+		@Override
+		public boolean equals(Object o) {
+			if (!(o instanceof NativeQuery that)) {
+				return false;
+			}
+			return ObjectUtils.nullSafeEquals(sql, that.sql);
+		}
+
+		@Override
+		public int hashCode() {
+			return ObjectUtils.nullSafeHashCode(sql);
+		}
+
+		@Override
+		public String toString() {
+			return "Native[" + sql + "]";
+		}
+
+	}
+
+	/**
+	 * A rewritten {@link DeclaredQuery} holding a reference to its original query.
+	 */
+	static class RewrittenQuery implements DeclaredQuery {
+
+		private final DeclaredQuery source;
+		private final String queryString;
+
+		public RewrittenQuery(DeclaredQuery source, String queryString) {
+			this.source = source;
+			this.queryString = queryString;
+		}
+
+		@Override
+		public boolean isNative() {
+			return source.isNative();
+		}
+
+		@Override
+		public String getQueryString() {
+			return queryString;
+		}
+
+		@Override
+		public boolean equals(Object o) {
+			if (!(o instanceof RewrittenQuery that)) {
+				return false;
+			}
+			return ObjectUtils.nullSafeEquals(queryString, that.queryString);
+		}
+
+		@Override
+		public int hashCode() {
+			return ObjectUtils.nullSafeHashCode(queryString);
+		}
+
+		@Override
+		public String toString() {
+			return isNative() ? "Rewritten Native[" + queryString + "]" : "Rewritten JPQL[" + queryString + "]";
+		}
+
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java
index 70bc5c829b..2cea734dbc 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java
@@ -15,99 +15,71 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import java.util.List;
-
-import org.springframework.lang.Nullable;
-import org.springframework.util.ObjectUtils;
-
 /**
- * A wrapper for a String representation of a query offering information about the query.
+ * Interface defining the contract to represent a declared query.
+ * <p>
+ * Declared queries consist of a query string and a flag whether the query is a native (SQL) one or a JPQL query.
+ * Queries can be rewritten to contain a different query string (i.e. count query derivation, sorting, projection
+ * updates) while retaining their {@link #isNative() native} flag.
  *
  * @author Jens Schauder
  * @author Diego Krupitza
+ * @author Mark Paluch
  * @since 2.0.3
  */
-interface DeclaredQuery {
+public interface DeclaredQuery extends QueryProvider {
 
 	/**
-	 * Creates a {@literal DeclaredQuery} from a query {@literal String}.
+	 * Creates a DeclaredQuery for a JPQL query.
 	 *
-	 * @param query might be {@literal null} or empty.
-	 * @param nativeQuery is a given query is native or not
-	 * @return a {@literal DeclaredQuery} instance even for a {@literal null} or empty argument.
+	 * @param jpql the JPQL query string.
+	 * @return new instance of {@link DeclaredQuery}.
 	 */
-	static DeclaredQuery of(@Nullable String query, boolean nativeQuery) {
-		return ObjectUtils.isEmpty(query) ? EmptyDeclaredQuery.EMPTY_QUERY : new StringQuery(query, nativeQuery);
+	static DeclaredQuery jpqlQuery(String jpql) {
+		return new DeclaredQueries.JpqlQuery(jpql);
 	}
 
 	/**
-	 * @return whether the underlying query has at least one named parameter.
-	 */
-	boolean hasNamedParameter();
-
-	/**
-	 * Returns the query string.
-	 */
-	String getQueryString();
-
-	/**
-	 * Returns the main alias used in the query.
+	 * Creates a DeclaredQuery for a native query.
 	 *
-	 * @return the alias
+	 * @param sql the native query string.
+	 * @return new instance of {@link DeclaredQuery}.
 	 */
-	@Nullable
-	String getAlias();
+	static DeclaredQuery nativeQuery(String sql) {
+		return new DeclaredQueries.NativeQuery(sql);
+	}
 
 	/**
-	 * Returns whether the query is using a constructor expression.
+	 * Return whether the query is a native query of not.
 	 *
-	 * @since 1.10
-	 */
-	boolean hasConstructorExpression();
-
-	/**
-	 * Returns whether the query uses the default projection, i.e. returns the main alias defined for the query.
+	 * @return {@literal true} if native query; {@literal false} if it is a JPQL query.
 	 */
-	boolean isDefaultProjection();
+	boolean isNative();
 
 	/**
-	 * Returns the {@link ParameterBinding}s registered.
-	 */
-	List<ParameterBinding> getParameterBindings();
-
-	/**
-	 * Creates a new {@literal DeclaredQuery} representing a count query, i.e. a query returning the number of rows to be
-	 * expected from the original query, either derived from the query wrapped by this instance or from the information
-	 * passed as arguments.
+	 * Return whether the query is a JPQL query of not.
 	 *
-	 * @param countQueryProjection an optional return type for the query.
-	 * @return a new {@literal DeclaredQuery} instance.
-	 */
-	DeclaredQuery deriveCountQuery(@Nullable String countQueryProjection);
-
-	/**
-	 * @return whether paging is implemented in the query itself, e.g. using SpEL expressions.
-	 * @since 2.0.6
+	 * @return {@literal true} if JPQL query; {@literal false} if it is a native query.
+	 * @since 4.0
 	 */
-	default boolean usesPaging() {
-		return false;
+	default boolean isJpql() {
+		return !isNative();
 	}
 
 	/**
-	 * Returns whether the query uses JDBC style parameters, i.e. parameters denoted by a simple ? without any index or
-	 * name.
+	 * Rewrite a query string using a new query string retaining its source and {@link #isNative() native} flag.
 	 *
-	 * @return Whether the query uses JDBC style parameters.
-	 * @since 2.0.6
+	 * @param newQueryString the new query string.
+	 * @return the rewritten {@link DeclaredQuery}.
+	 * @since 4.0
 	 */
-	boolean usesJdbcStyleParameters();
+	default DeclaredQuery rewrite(String newQueryString) {
 
-	/**
-	 * Return whether the query is a native query of not.
-	 *
-	 * @return <code>true</code> if native query otherwise <code>false</code>
-	 */
-	default boolean isNativeQuery() {
-		return false;
+		if (getQueryString().equals(newQueryString)) {
+			return this;
+		}
+
+		return new DeclaredQueries.RewrittenQuery(this, newQueryString);
 	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultEntityQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultEntityQuery.java
new file mode 100644
index 0000000000..d07e238f21
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultEntityQuery.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.query;
+
+import java.util.List;
+import java.util.function.Function;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Encapsulation of a JPA query string, typically returning entities or DTOs. Provides access to parameter bindings.
+ * <p>
+ * The internal {@link PreprocessedQuery query string} is cleaned from decorated parameters like {@literal %:lastname%}
+ * and the matching bindings take care of applying the decorations in the {@link ParameterBinding#prepare(Object)}
+ * method. Note that this class also handles replacing SpEL expressions with synthetic bind parameters.
+ *
+ * @author Oliver Gierke
+ * @author Thomas Darimont
+ * @author Oliver Wehrens
+ * @author Mark Paluch
+ * @author Jens Schauder
+ * @author Diego Krupitza
+ * @author Greg Turnquist
+ * @author Yuriy Tsarkov
+ * @since 4.0
+ */
+class DefaultEntityQuery implements EntityQuery, DeclaredQuery {
+
+	private final PreprocessedQuery query;
+	private final QueryEnhancer queryEnhancer;
+
+	DefaultEntityQuery(PreprocessedQuery query, QueryEnhancerFactory queryEnhancerFactory) {
+		this.query = query;
+		this.queryEnhancer = queryEnhancerFactory.create(query);
+	}
+
+	@Override
+	public <T> T doWithEnhancer(Function<QueryEnhancer, T> function) {
+		return function.apply(queryEnhancer);
+	}
+
+	@Override
+	public boolean isNative() {
+		return query.isNative();
+	}
+
+	@Override
+	public String getQueryString() {
+		return query.getQueryString();
+	}
+
+	/**
+	 * Returns whether we have found some like bindings.
+	 */
+	@Override
+	public boolean hasParameterBindings() {
+		return this.query.hasBindings();
+	}
+
+	@Override
+	public boolean usesJdbcStyleParameters() {
+		return query.usesJdbcStyleParameters();
+	}
+
+	@Override
+	public boolean hasNamedParameter() {
+		return query.hasNamedBindings();
+	}
+
+	@Override
+	public List<ParameterBinding> getParameterBindings() {
+		return this.query.getBindings();
+	}
+
+	@Override
+	public boolean hasConstructorExpression() {
+		return queryEnhancer.hasConstructorExpression();
+	}
+
+	@Override
+	public boolean isDefaultProjection() {
+		return queryEnhancer.getProjection().equalsIgnoreCase(getAlias());
+	}
+
+	@Nullable
+	String getAlias() {
+		return queryEnhancer.detectAlias();
+	}
+
+	@Override
+	public boolean usesPaging() {
+		return query.containsPageableInSpel();
+	}
+
+	String getProjection() {
+		return this.queryEnhancer.getProjection();
+	}
+
+	@Override
+	public ParametrizedQuery deriveCountQuery(@Nullable String countQueryProjection) {
+		return new SimpleParametrizedQuery(this.query.rewrite(queryEnhancer.createCountQueryFor(countQueryProjection)));
+	}
+
+	@Override
+	public QueryProvider rewrite(QueryEnhancer.QueryRewriteInformation rewriteInformation) {
+		return this.query.rewrite(queryEnhancer.rewrite(rewriteInformation));
+	}
+
+	@Override
+	public String toString() {
+		return "EntityQuery[" + getQueryString() + ", " + getParameterBindings() + ']';
+	}
+
+	/**
+	 * Simple {@link ParametrizedQuery} variant forwarding to {@link PreprocessedQuery}.
+	 */
+	static class SimpleParametrizedQuery implements ParametrizedQuery {
+
+		private final PreprocessedQuery query;
+
+		SimpleParametrizedQuery(PreprocessedQuery query) {
+			this.query = query;
+		}
+
+		@Override
+		public String getQueryString() {
+			return query.getQueryString();
+		}
+
+		@Override
+		public boolean hasParameterBindings() {
+			return query.hasBindings();
+		}
+
+		@Override
+		public boolean usesJdbcStyleParameters() {
+			return query.usesJdbcStyleParameters();
+		}
+
+		@Override
+		public boolean hasNamedParameter() {
+			return query.hasNamedBindings();
+		}
+
+		@Override
+		public List<ParameterBinding> getParameterBindings() {
+			return query.getBindings();
+		}
+
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java
index 8dba004f4b..456c3139b3 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java
@@ -15,10 +15,7 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import java.util.Set;
-
-import org.springframework.data.domain.Sort;
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * The implementation of the Regex-based {@link QueryEnhancer} using {@link QueryUtils}.
@@ -26,30 +23,18 @@
  * @author Diego Krupitza
  * @since 2.7.0
  */
-public class DefaultQueryEnhancer implements QueryEnhancer {
+class DefaultQueryEnhancer implements QueryEnhancer {
 
-	private final DeclaredQuery query;
+	private final QueryProvider query;
 	private final boolean hasConstructorExpression;
-	private final String alias;
+	private final @Nullable  String alias;
 	private final String projection;
-	private final Set<String> joinAliases;
 
-	public DefaultQueryEnhancer(DeclaredQuery query) {
+	public DefaultQueryEnhancer(QueryProvider query) {
 		this.query = query;
 		this.hasConstructorExpression = QueryUtils.hasConstructorExpression(query.getQueryString());
 		this.alias = QueryUtils.detectAlias(query.getQueryString());
 		this.projection = QueryUtils.getProjection(this.query.getQueryString());
-		this.joinAliases = QueryUtils.getOuterJoinAliases(this.query.getQueryString());
-	}
-
-	@Override
-	public String applySorting(Sort sort) {
-		return QueryUtils.applySorting(this.query.getQueryString(), sort, this.alias);
-	}
-
-	@Override
-	public String applySorting(Sort sort, @Nullable String alias) {
-		return QueryUtils.applySorting(this.query.getQueryString(), sort, alias);
 	}
 
 	@Override
@@ -59,7 +44,9 @@ public String rewrite(QueryRewriteInformation rewriteInformation) {
 
 	@Override
 	public String createCountQueryFor(@Nullable String countProjection) {
-		return QueryUtils.createCountQueryFor(this.query.getQueryString(), countProjection, this.query.isNativeQuery());
+
+		boolean nativeQuery = this.query instanceof DeclaredQuery dc ? dc.isNative() : true;
+		return QueryUtils.createCountQueryFor(this.query.getQueryString(), countProjection, nativeQuery);
 	}
 
 	@Override
@@ -68,7 +55,7 @@ public boolean hasConstructorExpression() {
 	}
 
 	@Override
-	public String detectAlias() {
+	public @Nullable String detectAlias() {
 		return this.alias;
 	}
 
@@ -78,12 +65,8 @@ public String getProjection() {
 	}
 
 	@Override
-	public Set<String> getJoinAliases() {
-		return this.joinAliases;
-	}
-
-	@Override
-	public DeclaredQuery getQuery() {
+	public QueryProvider getQuery() {
 		return this.query;
 	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java
index 4593697a4d..d57a83ab99 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java
@@ -57,7 +57,7 @@ public QueryTokenStream transformSelectionList(QueryTokenStream selectionList) {
 			builder.appendInline(QueryTokenStream.concat(returnedType.getInputProperties(), property -> {
 
 				QueryRenderer.QueryRendererBuilder prop = QueryRenderer.builder();
-				prop.append(QueryTokens.token(selectionList.getFirst().value()));
+				prop.append(QueryTokens.token(selectionList.getRequiredFirst().value()));
 				prop.append(QueryTokens.TOKEN_DOT);
 				prop.append(QueryTokens.token(property));
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyDeclaredQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyIntrospectedQuery.java
similarity index 61%
rename from spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyDeclaredQuery.java
rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyIntrospectedQuery.java
index 850c0919a3..188b0b8c23 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyDeclaredQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyIntrospectedQuery.java
@@ -17,21 +17,34 @@
 
 import java.util.Collections;
 import java.util.List;
+import java.util.function.Function;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
- * NULL-Object pattern implementation for {@link DeclaredQuery}.
+ * NULL-Object pattern implementation for {@link ParametrizedQuery}.
  *
  * @author Jens Schauder
+ * @author Mark Paluch
  * @since 2.0.3
  */
-class EmptyDeclaredQuery implements DeclaredQuery {
+enum EmptyIntrospectedQuery implements EntityQuery {
+
+	INSTANCE;
+
+	EmptyIntrospectedQuery() {}
+
 
-	/**
-	 * An implementation implementing the NULL-Object pattern for situations where there is no query.
-	 */
-	static final DeclaredQuery EMPTY_QUERY = new EmptyDeclaredQuery();
+
+	@Override
+	public boolean hasParameterBindings() {
+		return false;
+	}
+
+	@Override
+	public boolean usesJdbcStyleParameters() {
+		return false;
+	}
 
 	@Override
 	public boolean hasNamedParameter() {
@@ -39,12 +52,16 @@ public boolean hasNamedParameter() {
 	}
 
 	@Override
-	public String getQueryString() {
-		return "";
+	public List<ParameterBinding> getParameterBindings() {
+		return Collections.emptyList();
+	}
+
+	public @Nullable String getAlias() {
+		return null;
 	}
 
 	@Override
-	public String getAlias() {
+	public <T> T doWithEnhancer(Function<QueryEnhancer, T> function) {
 		return null;
 	}
 
@@ -53,23 +70,34 @@ public boolean hasConstructorExpression() {
 		return false;
 	}
 
+	@Override
+	public boolean isNative() {
+		return false;
+	}
+
 	@Override
 	public boolean isDefaultProjection() {
 		return false;
 	}
 
 	@Override
-	public List<ParameterBinding> getParameterBindings() {
-		return Collections.emptyList();
+	public String getQueryString() {
+		return "";
 	}
 
 	@Override
-	public DeclaredQuery deriveCountQuery(@Nullable String countQueryProjection) {
-		return EMPTY_QUERY;
+	public ParametrizedQuery deriveCountQuery(@Nullable String countQueryProjection) {
+		return INSTANCE;
 	}
 
 	@Override
-	public boolean usesJdbcStyleParameters() {
-		return false;
+	public QueryProvider rewrite(QueryEnhancer.QueryRewriteInformation rewriteInformation) {
+		return this;
 	}
+
+	@Override
+	public String toString() {
+		return "<EMPTY>";
+	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyQueryTokenStream.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyQueryTokenStream.java
index db498281fc..1b05738d5e 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyQueryTokenStream.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyQueryTokenStream.java
@@ -18,6 +18,8 @@
 import java.util.Collections;
 import java.util.Iterator;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * Empty QueryTokenStream.
  *
@@ -31,12 +33,12 @@ class EmptyQueryTokenStream implements QueryTokenStream {
 	private EmptyQueryTokenStream() {}
 
 	@Override
-	public QueryToken getFirst() {
+	public @Nullable QueryToken getFirst() {
 		return null;
 	}
 
 	@Override
-	public QueryToken getLast() {
+	public @Nullable QueryToken getLast() {
 		return null;
 	}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EntityQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EntityQuery.java
new file mode 100644
index 0000000000..0e22efa28a
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EntityQuery.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2018-2024 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.springframework.data.jpa.repository.query;
+
+import java.util.function.Function;
+
+import org.jspecify.annotations.Nullable;
+
+/**
+ * An extension to {@link ParametrizedQuery} exposing query information about its inner structure such as whether
+ * constructor expressions (JPQL) are used or the default projection is used.
+ * <p>
+ * Entity Queries support derivation of {@link #deriveCountQuery(String) count queries} from the original query. They
+ * also can be used to rewrite the query using sorting and projection selection.
+ *
+ * @author Jens Schauder
+ * @author Diego Krupitza
+ * @since 4.0
+ */
+public interface EntityQuery extends ParametrizedQuery {
+
+	/**
+	 * Create a new {@link EntityQuery} given {@link DeclaredQuery} and {@link QueryEnhancerSelector}.
+	 *
+	 * @param query must not be {@literal null}.
+	 * @param selector must not be {@literal null}.
+	 * @return a new {@link EntityQuery}.
+	 */
+	static EntityQuery create(DeclaredQuery query, QueryEnhancerSelector selector) {
+
+		PreprocessedQuery preparsed = PreprocessedQuery.parse(query);
+		QueryEnhancerFactory enhancerFactory = selector.select(preparsed);
+
+		return new DefaultEntityQuery(preparsed, enhancerFactory);
+	}
+
+	/**
+	 * Apply a {@link Function} to the query enhancer used by this query.
+	 *
+	 * @param function the callback function.
+	 * @return
+	 * @param <T>
+	 */
+	<T extends @Nullable Object> T doWithEnhancer(Function<QueryEnhancer, T> function);
+
+	/**
+	 * Returns whether the query is using a constructor expression.
+	 *
+	 * @since 1.10
+	 */
+	boolean hasConstructorExpression();
+
+	/**
+	 * @return whether the underlying query has at least one named parameter.
+	 */
+	boolean isNative();
+
+	/**
+	 * Returns whether the query uses the default projection, i.e. returns the main alias defined for the query.
+	 */
+	boolean isDefaultProjection();
+
+	/**
+	 * @return whether paging is implemented in the query itself, e.g. using SpEL expressions.
+	 * @since 2.0.6
+	 */
+	default boolean usesPaging() {
+		return false;
+	}
+
+	/**
+	 * Creates a new {@literal IntrospectedQuery} representing a count query, i.e. a query returning the number of rows to
+	 * be expected from the original query, either derived from the query wrapped by this instance or from the information
+	 * passed as arguments.
+	 *
+	 * @param countQueryProjection an optional return type for the query.
+	 * @return a new {@literal IntrospectedQuery} instance.
+	 */
+	ParametrizedQuery deriveCountQuery(@Nullable String countQueryProjection);
+
+	/**
+	 * Rewrite the query using the given
+	 * {@link org.springframework.data.jpa.repository.query.QueryEnhancer.QueryRewriteInformation} into a sorted query or
+	 * using a different projection. The rewritten query retains parameter binding characteristics.
+	 *
+	 * @param rewriteInformation query rewrite information (sorting, projection) to use.
+	 * @return the rewritten query.
+	 */
+	QueryProvider rewrite(QueryEnhancer.QueryRewriteInformation rewriteInformation);
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlCountQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlCountQueryTransformer.java
index 0221aff83a..2d8e27c167 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlCountQueryTransformer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlCountQueryTransformer.java
@@ -18,8 +18,9 @@
 import static org.springframework.data.jpa.repository.query.QueryTokens.*;
 
 import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.query.QueryTransformers.CountSelectionTokenStream;
-import org.springframework.lang.Nullable;
 
 /**
  * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed EQL query into a
@@ -30,7 +31,7 @@
  * @author Christoph Strobl
  * @since 3.4
  */
-@SuppressWarnings("ConstantValue")
+@SuppressWarnings({ "ConstantValue", "NullAway" })
 class EqlCountQueryTransformer extends EqlQueryRenderer {
 
 	private final @Nullable String countProjection;
@@ -42,7 +43,7 @@ class EqlCountQueryTransformer extends EqlQueryRenderer {
 	}
 
 	@Override
-	public QueryRendererBuilder visitSelect_statement(EqlParser.Select_statementContext ctx) {
+	public QueryTokenStream visitSelect_statement(EqlParser.Select_statementContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
@@ -92,7 +93,7 @@ public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) {
 		return builder;
 	}
 
-	private QueryRendererBuilder getDistinctCountSelection(QueryTokenStream selectionListbuilder) {
+	private QueryTokenStream getDistinctCountSelection(QueryTokenStream selectionListbuilder) {
 
 		QueryRendererBuilder nested = new QueryRendererBuilder();
 		CountSelectionTokenStream countSelection = CountSelectionTokenStream.create(selectionListbuilder);
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryIntrospector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryIntrospector.java
index 0f006f2388..fa7fa5ec8e 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryIntrospector.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryIntrospector.java
@@ -22,7 +22,8 @@
 import java.util.List;
 
 import org.springframework.data.jpa.repository.query.EqlParser.Range_variable_declarationContext;
-import org.springframework.lang.Nullable;
+
+import org.jspecify.annotations.Nullable;
 
 /**
  * {@link ParsedQueryIntrospector} for EQL queries.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryRenderer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryRenderer.java
index 8225545c83..04626dbb12 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryRenderer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryRenderer.java
@@ -23,6 +23,7 @@
 import org.antlr.v4.runtime.tree.ParseTree;
 
 import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
+import org.springframework.util.ObjectUtils;
 
 /**
  * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that renders an EQL query without making any changes.
@@ -77,30 +78,8 @@ public QueryTokenStream visitSelect_statement(EqlParser.Select_statementContext
 			builder.appendExpression(visit(ctx.orderby_clause()));
 		}
 
-		for (int i = 0; i < ctx.setOperator().size(); i++) {
-
-			builder.appendExpression(visit(ctx.setOperator(i)));
-			builder.appendExpression(visit(ctx.select_statement(i)));
-		}
-
-		return builder;
-	}
-
-	@Override
-	public QueryTokenStream visitSetOperator(EqlParser.SetOperatorContext ctx) {
-
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
-		if (ctx.UNION() != null) {
-			builder.append(QueryTokens.expression(ctx.UNION()));
-		} else if (ctx.INTERSECT() != null) {
-			builder.append(QueryTokens.expression(ctx.INTERSECT()));
-		} else if (ctx.EXCEPT() != null) {
-			builder.append(QueryTokens.expression(ctx.EXCEPT()));
-		}
-
-		if (ctx.ALL() != null) {
-			builder.append(QueryTokens.expression(ctx.ALL()));
+		if (ctx.set_fuction() != null) {
+			builder.appendExpression(visit(ctx.set_fuction()));
 		}
 
 		return builder;
@@ -184,13 +163,8 @@ public QueryTokenStream visitIdentification_variable_declaration(
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
 		builder.append(visit(ctx.range_variable_declaration()));
-
-		ctx.join().forEach(joinContext -> {
-			builder.append(visit(joinContext));
-		});
-		ctx.fetch_join().forEach(fetchJoinContext -> {
-			builder.append(visit(fetchJoinContext));
-		});
+		builder.appendExpression(QueryTokenStream.concat(ctx.join(), this::visit, TOKEN_SPACE));
+		builder.appendExpression(QueryTokenStream.concat(ctx.fetch_join(), this::visit, TOKEN_SPACE));
 
 		return builder;
 	}
@@ -226,9 +200,11 @@ public QueryTokenStream visitJoin(EqlParser.JoinContext ctx) {
 		if (ctx.AS() != null) {
 			builder.append(QueryTokens.expression(ctx.AS()));
 		}
+
 		if (ctx.identification_variable() != null) {
 			builder.appendExpression(visit(ctx.identification_variable()));
 		}
+
 		if (ctx.join_condition() != null) {
 			builder.appendExpression(visit(ctx.join_condition()));
 		}
@@ -291,8 +267,7 @@ public QueryTokenStream visitJoin_condition(EqlParser.Join_conditionContext ctx)
 	}
 
 	@Override
-	public QueryTokenStream visitJoin_association_path_expression(
-			EqlParser.Join_association_path_expressionContext ctx) {
+	public QueryTokenStream visitJoin_association_path_expression(EqlParser.Join_association_path_expressionContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
@@ -304,31 +279,25 @@ public QueryTokenStream visitJoin_association_path_expression(
 				builder.appendExpression(visit(ctx.join_single_valued_path_expression()));
 			}
 		} else {
-			if (ctx.join_collection_valued_path_expression() != null) {
+			QueryRendererBuilder nested = QueryRenderer.builder();
 
-				QueryRendererBuilder nested = QueryRenderer.builder();
+			if (ctx.join_collection_valued_path_expression() != null) {
 
-				nested.append(QueryTokens.token(ctx.TREAT()));
-				nested.append(TOKEN_OPEN_PAREN);
-				nested.appendInline(visit(ctx.join_collection_valued_path_expression()));
+				nested.appendExpression(visit(ctx.join_collection_valued_path_expression()));
 				nested.append(QueryTokens.expression(ctx.AS()));
-				nested.appendInline(visit(ctx.subtype()));
-				nested.append(TOKEN_CLOSE_PAREN);
+				nested.appendExpression(visit(ctx.subtype()));
 
-				builder.appendExpression(nested);
 			} else if (ctx.join_single_valued_path_expression() != null) {
 
-				QueryRendererBuilder nested = QueryRenderer.builder();
-
-				nested.append(QueryTokens.token(ctx.TREAT()));
-				nested.append(TOKEN_OPEN_PAREN);
-				nested.appendInline(visit(ctx.join_single_valued_path_expression()));
+				nested.appendExpression(visit(ctx.join_single_valued_path_expression()));
 				nested.append(QueryTokens.expression(ctx.AS()));
-				nested.appendInline(visit(ctx.subtype()));
-				nested.append(TOKEN_CLOSE_PAREN);
-
-				builder.appendExpression(nested);
+				nested.appendExpression(visit(ctx.subtype()));
 			}
+
+			builder.append(QueryTokens.token(ctx.TREAT()));
+			builder.append(TOKEN_OPEN_PAREN);
+			builder.appendInline(nested);
+			builder.append(TOKEN_CLOSE_PAREN);
 		}
 
 		return builder;
@@ -450,8 +419,7 @@ public QueryTokenStream visitSingle_valued_path_expression(EqlParser.Single_valu
 	}
 
 	@Override
-	public QueryTokenStream visitGeneral_identification_variable(
-			EqlParser.General_identification_variableContext ctx) {
+	public QueryTokenStream visitGeneral_identification_variable(EqlParser.General_identification_variableContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
@@ -494,12 +462,15 @@ public QueryTokenStream visitSimple_subpath(EqlParser.Simple_subpathContext ctx)
 	public QueryTokenStream visitTreated_subpath(EqlParser.Treated_subpathContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
+		QueryRendererBuilder nested = QueryRenderer.builder();
+
+		nested.appendExpression(visit(ctx.general_subpath()));
+		nested.append(QueryTokens.expression(ctx.AS()));
+		nested.appendExpression(visit(ctx.subtype()));
 
 		builder.append(QueryTokens.token(ctx.TREAT()));
 		builder.append(TOKEN_OPEN_PAREN);
-		builder.appendInline(visit(ctx.general_subpath()));
-		builder.append(QueryTokens.expression(ctx.AS()));
-		builder.appendInline(visit(ctx.subtype()));
+		builder.appendInline(nested);
 		builder.append(TOKEN_CLOSE_PAREN);
 
 		return builder;
@@ -612,7 +583,7 @@ public QueryTokenStream visitNew_value(EqlParser.New_valueContext ctx) {
 		} else if (ctx.simple_entity_expression() != null) {
 			return visit(ctx.simple_entity_expression());
 		} else if (ctx.NULL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.NULL()));
+			return QueryTokenStream.ofToken(ctx.NULL());
 		} else {
 			return QueryRenderer.builder();
 		}
@@ -719,19 +690,19 @@ public QueryTokenStream visitConstructor_expression(EqlParser.Constructor_expres
 	@Override
 	public QueryTokenStream visitConstructor_item(EqlParser.Constructor_itemContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.single_valued_path_expression() != null) {
-			builder.append(visit(ctx.single_valued_path_expression()));
+			return visit(ctx.single_valued_path_expression());
 		} else if (ctx.scalar_expression() != null) {
-			builder.append(visit(ctx.scalar_expression()));
+			return visit(ctx.scalar_expression());
 		} else if (ctx.aggregate_expression() != null) {
-			builder.append(visit(ctx.aggregate_expression()));
+			return visit(ctx.aggregate_expression());
 		} else if (ctx.identification_variable() != null) {
-			builder.append(visit(ctx.identification_variable()));
+			return visit(ctx.identification_variable());
+		} else if (ctx.literal() != null) {
+			return visit(ctx.literal());
 		}
 
-		return builder;
+		return QueryTokenStream.empty();
 	}
 
 	@Override
@@ -852,15 +823,15 @@ public QueryTokenStream visitOrderby_item(EqlParser.Orderby_itemContext ctx) {
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
 		if (ctx.state_field_path_expression() != null) {
-			builder.append(visit(ctx.state_field_path_expression()));
+			builder.appendExpression(visit(ctx.state_field_path_expression()));
 		} else if (ctx.general_identification_variable() != null) {
-			builder.append(visit(ctx.general_identification_variable()));
+			builder.appendExpression(visit(ctx.general_identification_variable()));
 		} else if (ctx.result_variable() != null) {
-			builder.append(visit(ctx.result_variable()));
+			builder.appendExpression(visit(ctx.result_variable()));
 		} else if (ctx.string_expression() != null) {
-			builder.append(visit(ctx.string_expression()));
+			builder.appendExpression(visit(ctx.string_expression()));
 		} else if (ctx.scalar_expression() != null) {
-			builder.append(visit(ctx.scalar_expression()));
+			builder.appendExpression(visit(ctx.scalar_expression()));
 		}
 
 		if (ctx.ASC() != null) {
@@ -882,12 +853,44 @@ public QueryTokenStream visitNullsPrecedence(EqlParser.NullsPrecedenceContext ct
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(TOKEN_NULLS);
+		builder.append(QueryTokens.expression(ctx.NULLS()));
 
 		if (ctx.FIRST() != null) {
-			builder.append(TOKEN_FIRST);
+			builder.append(QueryTokens.expression(ctx.FIRST()));
 		} else if (ctx.LAST() != null) {
-			builder.append(TOKEN_LAST);
+			builder.append(QueryTokens.expression(ctx.LAST()));
+		}
+
+		return builder;
+	}
+
+	@Override
+	public QueryTokenStream visitSet_fuction(EqlParser.Set_fuctionContext ctx) {
+
+		QueryRendererBuilder builder = QueryRenderer.builder();
+
+		if (ctx.setOperator() != null) {
+			builder.append(visit(ctx.setOperator()));
+		}
+
+		builder.appendExpression(visit(ctx.select_statement()));
+
+		return builder;
+	}
+
+	@Override
+	public QueryTokenStream visitSetOperator(EqlParser.SetOperatorContext ctx) {
+
+		QueryRendererBuilder builder = QueryRenderer.builder();
+
+		if (ctx.INTERSECT() != null) {
+			builder.append(QueryTokens.expression(ctx.INTERSECT()));
+		} else if (ctx.UNION() != null) {
+			builder.append(QueryTokens.expression(ctx.UNION()));
+		} else if (ctx.EXCEPT() != null) {
+			builder.append(QueryTokens.expression(ctx.EXCEPT()));
+		} else if (ctx.ALL() != null) {
+			builder.append(QueryTokens.expression(ctx.ALL()));
 		}
 
 		return builder;
@@ -992,81 +995,82 @@ public QueryTokenStream visitSimple_select_expression(EqlParser.Simple_select_ex
 	@Override
 	public QueryTokenStream visitScalar_expression(EqlParser.Scalar_expressionContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.arithmetic_expression() != null) {
-			builder.append(visit(ctx.arithmetic_expression()));
+			return visit(ctx.arithmetic_expression());
 		} else if (ctx.string_expression() != null) {
-			builder.append(visit(ctx.string_expression()));
+			return visit(ctx.string_expression());
 		} else if (ctx.enum_expression() != null) {
-			builder.append(visit(ctx.enum_expression()));
+			return visit(ctx.enum_expression());
 		} else if (ctx.datetime_expression() != null) {
-			builder.append(visit(ctx.datetime_expression()));
+			return visit(ctx.datetime_expression());
 		} else if (ctx.boolean_expression() != null) {
-			builder.append(visit(ctx.boolean_expression()));
+			return visit(ctx.boolean_expression());
 		} else if (ctx.case_expression() != null) {
-			builder.append(visit(ctx.case_expression()));
+			return visit(ctx.case_expression());
 		} else if (ctx.entity_type_expression() != null) {
-			builder.append(visit(ctx.entity_type_expression()));
+			return visit(ctx.entity_type_expression());
+		} else if (ctx.cast_function() != null) {
+			return (visit(ctx.cast_function()));
 		}
 
-		return builder;
+		return QueryTokenStream.empty();
 	}
 
 	@Override
 	public QueryTokenStream visitConditional_expression(EqlParser.Conditional_expressionContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.conditional_expression() != null) {
-			builder.append(visit(ctx.conditional_expression()));
+			QueryRendererBuilder builder = QueryRenderer.builder();
+
+			builder.appendExpression(visit(ctx.conditional_expression()));
 			builder.append(QueryTokens.expression(ctx.OR()));
-			builder.append(visit(ctx.conditional_term()));
+			builder.appendExpression(visit(ctx.conditional_term()));
+
+			return builder;
 		} else {
-			builder.append(visit(ctx.conditional_term()));
+			return visit(ctx.conditional_term());
 		}
-
-		return builder;
 	}
 
 	@Override
 	public QueryTokenStream visitConditional_term(EqlParser.Conditional_termContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.conditional_term() != null) {
-			builder.append(visit(ctx.conditional_term()));
+			QueryRendererBuilder builder = QueryRenderer.builder();
+
+			builder.appendExpression(visit(ctx.conditional_term()));
 			builder.append(QueryTokens.expression(ctx.AND()));
-			builder.append(visit(ctx.conditional_factor()));
+			builder.appendExpression(visit(ctx.conditional_factor()));
+
+			return builder;
 		} else {
-			builder.append(visit(ctx.conditional_factor()));
+			return visit(ctx.conditional_factor());
 		}
-
-		return builder;
 	}
 
 	@Override
 	public QueryTokenStream visitConditional_factor(EqlParser.Conditional_factorContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.NOT() != null) {
+			QueryRendererBuilder builder = QueryRenderer.builder();
 			builder.append(QueryTokens.expression(ctx.NOT()));
+			builder.appendExpression(visit(ctx.conditional_primary()));
+			return builder;
 		}
 
-		builder.append(visit(ctx.conditional_primary()));
-
-		return builder;
+		return visit(ctx.conditional_primary());
 	}
 
 	@Override
 	public QueryTokenStream visitConditional_primary(EqlParser.Conditional_primaryContext ctx) {
 
+		if (ctx.simple_cond_expression() != null) {
+			return visit(ctx.simple_cond_expression());
+		}
+
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		if (ctx.simple_cond_expression() != null) {
-			builder.append(visit(ctx.simple_cond_expression()));
-		} else if (ctx.conditional_expression() != null) {
+		if (ctx.conditional_expression() != null) {
 
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.appendInline(visit(ctx.conditional_expression()));
@@ -1079,27 +1083,25 @@ public QueryTokenStream visitConditional_primary(EqlParser.Conditional_primaryCo
 	@Override
 	public QueryTokenStream visitSimple_cond_expression(EqlParser.Simple_cond_expressionContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.comparison_expression() != null) {
-			builder.append(visit(ctx.comparison_expression()));
+			return visit(ctx.comparison_expression());
 		} else if (ctx.between_expression() != null) {
-			builder.append(visit(ctx.between_expression()));
+			return visit(ctx.between_expression());
 		} else if (ctx.in_expression() != null) {
-			builder.append(visit(ctx.in_expression()));
+			return visit(ctx.in_expression());
 		} else if (ctx.like_expression() != null) {
-			builder.append(visit(ctx.like_expression()));
+			return visit(ctx.like_expression());
 		} else if (ctx.null_comparison_expression() != null) {
-			builder.append(visit(ctx.null_comparison_expression()));
+			return visit(ctx.null_comparison_expression());
 		} else if (ctx.empty_collection_comparison_expression() != null) {
-			builder.append(visit(ctx.empty_collection_comparison_expression()));
+			return visit(ctx.empty_collection_comparison_expression());
 		} else if (ctx.collection_member_expression() != null) {
-			builder.append(visit(ctx.collection_member_expression()));
+			return visit(ctx.collection_member_expression());
 		} else if (ctx.exists_expression() != null) {
-			builder.append(visit(ctx.exists_expression()));
+			return visit(ctx.exists_expression());
 		}
 
-		return builder;
+		return QueryTokenStream.empty();
 	}
 
 	@Override
@@ -1109,7 +1111,7 @@ public QueryTokenStream visitBetween_expression(EqlParser.Between_expressionCont
 
 		if (ctx.arithmetic_expression(0) != null) {
 
-			builder.append(visit(ctx.arithmetic_expression(0)));
+			builder.appendExpression(visit(ctx.arithmetic_expression(0)));
 
 			if (ctx.NOT() != null) {
 				builder.append(QueryTokens.expression(ctx.NOT()));
@@ -1135,7 +1137,7 @@ public QueryTokenStream visitBetween_expression(EqlParser.Between_expressionCont
 
 		} else if (ctx.datetime_expression(0) != null) {
 
-			builder.append(visit(ctx.datetime_expression(0)));
+			builder.appendExpression(visit(ctx.datetime_expression(0)));
 
 			if (ctx.NOT() != null) {
 				builder.append(QueryTokens.expression(ctx.NOT()));
@@ -1156,10 +1158,10 @@ public QueryTokenStream visitIn_expression(EqlParser.In_expressionContext ctx) {
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
 		if (ctx.string_expression() != null) {
-			builder.append(visit(ctx.string_expression()));
+			builder.appendExpression(visit(ctx.string_expression()));
 		}
 		if (ctx.type_discriminator() != null) {
-			builder.append(visit(ctx.type_discriminator()));
+			builder.appendExpression(visit(ctx.type_discriminator()));
 		}
 		if (ctx.NOT() != null) {
 			builder.append(QueryTokens.expression(ctx.NOT()));
@@ -1172,7 +1174,6 @@ public QueryTokenStream visitIn_expression(EqlParser.In_expressionContext ctx) {
 
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.appendInline(QueryTokenStream.concat(ctx.in_item(), this::visit, TOKEN_COMMA));
-
 			builder.append(TOKEN_CLOSE_PAREN);
 		} else if (ctx.subquery() != null) {
 
@@ -1213,7 +1214,8 @@ public QueryTokenStream visitLike_expression(EqlParser.Like_expressionContext ct
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(visit(ctx.string_expression()));
+		builder.appendExpression(visit(ctx.string_expression()));
+
 		if (ctx.NOT() != null) {
 			builder.append(QueryTokens.expression(ctx.NOT()));
 		}
@@ -1235,11 +1237,11 @@ public QueryTokenStream visitNull_comparison_expression(EqlParser.Null_compariso
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
 		if (ctx.single_valued_path_expression() != null) {
-			builder.append(visit(ctx.single_valued_path_expression()));
+			builder.appendExpression(visit(ctx.single_valued_path_expression()));
 		} else if (ctx.input_parameter() != null) {
-			builder.append(visit(ctx.input_parameter()));
+			builder.appendExpression(visit(ctx.input_parameter()));
 		} else if (ctx.nullif_expression() != null) {
-			builder.append(visit(ctx.nullif_expression()));
+			builder.appendExpression(visit(ctx.nullif_expression()));
 		}
 
 		if (ctx.op != null) {
@@ -1261,7 +1263,7 @@ public QueryTokenStream visitEmpty_collection_comparison_expression(
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(visit(ctx.collection_valued_path_expression()));
+		builder.appendExpression(visit(ctx.collection_valued_path_expression()));
 		builder.append(QueryTokens.expression(ctx.IS()));
 		if (ctx.NOT() != null) {
 			builder.append(QueryTokens.expression(ctx.NOT()));
@@ -1276,7 +1278,7 @@ public QueryTokenStream visitCollection_member_expression(EqlParser.Collection_m
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(visit(ctx.entity_or_value_expression()));
+		builder.appendExpression(visit(ctx.entity_or_value_expression()));
 		if (ctx.NOT() != null) {
 			builder.append(QueryTokens.expression(ctx.NOT()));
 		}
@@ -1292,34 +1294,30 @@ public QueryTokenStream visitCollection_member_expression(EqlParser.Collection_m
 	@Override
 	public QueryTokenStream visitEntity_or_value_expression(EqlParser.Entity_or_value_expressionContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.single_valued_object_path_expression() != null) {
-			builder.append(visit(ctx.single_valued_object_path_expression()));
+			return visit(ctx.single_valued_object_path_expression());
 		} else if (ctx.state_field_path_expression() != null) {
-			builder.append(visit(ctx.state_field_path_expression()));
+			return visit(ctx.state_field_path_expression());
 		} else if (ctx.simple_entity_or_value_expression() != null) {
-			builder.append(visit(ctx.simple_entity_or_value_expression()));
+			return visit(ctx.simple_entity_or_value_expression());
 		}
 
-		return builder;
+		return QueryTokenStream.empty();
 	}
 
 	@Override
 	public QueryTokenStream visitSimple_entity_or_value_expression(
 			EqlParser.Simple_entity_or_value_expressionContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.identification_variable() != null) {
-			builder.append(visit(ctx.identification_variable()));
+			return visit(ctx.identification_variable());
 		} else if (ctx.input_parameter() != null) {
-			builder.append(visit(ctx.input_parameter()));
+			return visit(ctx.input_parameter());
 		} else if (ctx.literal() != null) {
-			builder.append(visit(ctx.literal()));
+			return visit(ctx.literal());
 		}
 
-		return builder;
+		return QueryTokenStream.empty();
 	}
 
 	@Override
@@ -1364,13 +1362,13 @@ public QueryTokenStream visitStringComparison(EqlParser.StringComparisonContext
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.appendInline(visit(ctx.string_expression(0)));
-		builder.append(visit(ctx.comparison_operator()));
+		builder.appendExpression(visit(ctx.string_expression(0)));
+		builder.appendExpression(visit(ctx.comparison_operator()));
 
 		if (ctx.string_expression(1) != null) {
-			builder.append(visit(ctx.string_expression(1)));
+			builder.appendExpression(visit(ctx.string_expression(1)));
 		} else {
-			builder.append(visit(ctx.all_or_any_expression()));
+			builder.appendExpression(visit(ctx.all_or_any_expression()));
 		}
 
 		return builder;
@@ -1385,9 +1383,9 @@ public QueryTokenStream visitBooleanComparison(EqlParser.BooleanComparisonContex
 		builder.append(QueryTokens.ventilated(ctx.op));
 
 		if (ctx.boolean_expression(1) != null) {
-			builder.append(visit(ctx.boolean_expression(1)));
+			builder.appendExpression(visit(ctx.boolean_expression(1)));
 		} else {
-			builder.append(visit(ctx.all_or_any_expression()));
+			builder.appendExpression(visit(ctx.all_or_any_expression()));
 		}
 
 		return builder;
@@ -1407,9 +1405,9 @@ public QueryTokenStream visitEnumComparison(EqlParser.EnumComparisonContext ctx)
 		builder.append(QueryTokens.ventilated(ctx.op));
 
 		if (ctx.enum_expression(1) != null) {
-			builder.append(visit(ctx.enum_expression(1)));
+			builder.appendExpression(visit(ctx.enum_expression(1)));
 		} else {
-			builder.append(visit(ctx.all_or_any_expression()));
+			builder.appendExpression(visit(ctx.all_or_any_expression()));
 		}
 
 		return builder;
@@ -1424,9 +1422,9 @@ public QueryTokenStream visitDatetimeComparison(EqlParser.DatetimeComparisonCont
 		builder.append(QueryTokens.ventilated(ctx.comparison_operator().op));
 
 		if (ctx.datetime_expression(1) != null) {
-			builder.append(visit(ctx.datetime_expression(1)));
+			builder.appendExpression(visit(ctx.datetime_expression(1)));
 		} else {
-			builder.append(visit(ctx.all_or_any_expression()));
+			builder.appendExpression(visit(ctx.all_or_any_expression()));
 		}
 
 		return builder;
@@ -1441,9 +1439,9 @@ public QueryTokenStream visitEntityComparison(EqlParser.EntityComparisonContext
 		builder.append(QueryTokens.expression(ctx.op));
 
 		if (ctx.entity_expression(1) != null) {
-			builder.append(visit(ctx.entity_expression(1)));
+			builder.appendExpression(visit(ctx.entity_expression(1)));
 		} else {
-			builder.append(visit(ctx.all_or_any_expression()));
+			builder.appendExpression(visit(ctx.all_or_any_expression()));
 		}
 
 		return builder;
@@ -1454,13 +1452,13 @@ public QueryTokenStream visitArithmeticComparison(EqlParser.ArithmeticComparison
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(visit(ctx.arithmetic_expression(0)));
-		builder.append(visit(ctx.comparison_operator()));
+		builder.appendExpression(visit(ctx.arithmetic_expression(0)));
+		builder.appendExpression(visit(ctx.comparison_operator()));
 
 		if (ctx.arithmetic_expression(1) != null) {
-			builder.append(visit(ctx.arithmetic_expression(1)));
+			builder.appendExpression(visit(ctx.arithmetic_expression(1)));
 		} else {
-			builder.append(visit(ctx.all_or_any_expression()));
+			builder.appendExpression(visit(ctx.all_or_any_expression()));
 		}
 
 		return builder;
@@ -1473,7 +1471,7 @@ public QueryTokenStream visitEntityTypeComparison(EqlParser.EntityTypeComparison
 
 		builder.appendInline(visit(ctx.entity_type_expression(0)));
 		builder.append(QueryTokens.ventilated(ctx.op));
-		builder.append(visit(ctx.entity_type_expression(1)));
+		builder.appendExpression(visit(ctx.entity_type_expression(1)));
 
 		return builder;
 	}
@@ -1492,42 +1490,37 @@ public QueryTokenStream visitRegexpComparison(EqlParser.RegexpComparisonContext
 
 	@Override
 	public QueryTokenStream visitComparison_operator(EqlParser.Comparison_operatorContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.ventilated(ctx.op));
+		return QueryTokenStream.ofToken(ctx.op);
 	}
 
 	@Override
 	public QueryTokenStream visitArithmetic_expression(EqlParser.Arithmetic_expressionContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.arithmetic_expression() != null) {
 
+			QueryRendererBuilder builder = QueryRenderer.builder();
 			builder.append(visit(ctx.arithmetic_expression()));
-			builder.append(QueryTokens.expression(ctx.op));
+			builder.append(QueryTokens.ventilated(ctx.op));
 			builder.append(visit(ctx.arithmetic_term()));
+			return builder;
 
 		} else {
-			builder.append(visit(ctx.arithmetic_term()));
+			return visit(ctx.arithmetic_term());
 		}
-
-		return builder;
 	}
 
 	@Override
 	public QueryTokenStream visitArithmetic_term(EqlParser.Arithmetic_termContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.arithmetic_term() != null) {
-
+			QueryRendererBuilder builder = QueryRenderer.builder();
 			builder.appendInline(visit(ctx.arithmetic_term()));
 			builder.append(QueryTokens.ventilated(ctx.op));
 			builder.append(visit(ctx.arithmetic_factor()));
+			return builder;
 		} else {
-			builder.append(visit(ctx.arithmetic_factor()));
+			return visit(ctx.arithmetic_factor());
 		}
-
-		return builder;
 	}
 
 	@Override
@@ -1538,7 +1531,8 @@ public QueryTokenStream visitArithmetic_factor(EqlParser.Arithmetic_factorContex
 		if (ctx.op != null) {
 			builder.append(QueryTokens.token(ctx.op));
 		}
-		builder.appendInline(visit(ctx.arithmetic_primary()));
+
+		builder.append(visit(ctx.arithmetic_primary()));
 
 		return builder;
 	}
@@ -1603,6 +1597,11 @@ public QueryTokenStream visitString_expression(EqlParser.String_expressionContex
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.appendInline(visit(ctx.subquery()));
 			builder.append(TOKEN_CLOSE_PAREN);
+		} else if (!ObjectUtils.isEmpty(ctx.string_expression())) {
+
+			builder.appendInline(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_DOUBLE_PIPE);
+			builder.appendExpression(visit(ctx.string_expression(1)));
 		}
 
 		return builder;
@@ -1690,45 +1689,39 @@ public QueryTokenStream visitEnum_expression(EqlParser.Enum_expressionContext ct
 	@Override
 	public QueryTokenStream visitEntity_expression(EqlParser.Entity_expressionContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.single_valued_object_path_expression() != null) {
-			builder.append(visit(ctx.single_valued_object_path_expression()));
+			return visit(ctx.single_valued_object_path_expression());
 		} else if (ctx.simple_entity_expression() != null) {
-			builder.append(visit(ctx.simple_entity_expression()));
+			return visit(ctx.simple_entity_expression());
 		}
 
-		return builder;
+		return QueryTokenStream.empty();
 	}
 
 	@Override
 	public QueryTokenStream visitSimple_entity_expression(EqlParser.Simple_entity_expressionContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.identification_variable() != null) {
-			builder.append(visit(ctx.identification_variable()));
+			return visit(ctx.identification_variable());
 		} else if (ctx.input_parameter() != null) {
-			builder.append(visit(ctx.input_parameter()));
+			return visit(ctx.input_parameter());
 		}
 
-		return builder;
+		return QueryTokenStream.empty();
 	}
 
 	@Override
 	public QueryTokenStream visitEntity_type_expression(EqlParser.Entity_type_expressionContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.type_discriminator() != null) {
-			builder.append(visit(ctx.type_discriminator()));
+			return visit(ctx.type_discriminator());
 		} else if (ctx.entity_type_literal() != null) {
-			builder.append(visit(ctx.entity_type_literal()));
+			return visit(ctx.entity_type_literal());
 		} else if (ctx.input_parameter() != null) {
-			builder.append(visit(ctx.input_parameter()));
+			return visit(ctx.input_parameter());
 		}
 
-		return builder;
+		return QueryTokenStream.empty();
 	}
 
 	@Override
@@ -1903,7 +1896,7 @@ public QueryTokenStream visitFunctions_returning_strings(EqlParser.Functions_ret
 
 			builder.append(QueryTokens.token(ctx.SUBSTRING()));
 			builder.append(TOKEN_OPEN_PAREN);
-			builder.appendInline(visit(ctx.string_expression(0)));
+			builder.append(visit(ctx.string_expression(0)));
 			builder.append(TOKEN_COMMA);
 			builder.appendInline(QueryTokenStream.concat(ctx.arithmetic_expression(), this::visit, TOKEN_COMMA));
 			builder.append(TOKEN_CLOSE_PAREN);
@@ -1911,16 +1904,19 @@ public QueryTokenStream visitFunctions_returning_strings(EqlParser.Functions_ret
 
 			builder.append(QueryTokens.token(ctx.TRIM()));
 			builder.append(TOKEN_OPEN_PAREN);
+
+			QueryRendererBuilder nested = QueryRenderer.builder();
 			if (ctx.trim_specification() != null) {
-				builder.appendExpression(visit(ctx.trim_specification()));
+				nested.appendExpression(visit(ctx.trim_specification()));
 			}
 			if (ctx.trim_character() != null) {
-				builder.appendExpression(visit(ctx.trim_character()));
+				nested.appendExpression(visit(ctx.trim_character()));
 			}
 			if (ctx.FROM() != null) {
-				builder.append(QueryTokens.expression(ctx.FROM()));
+				nested.append(QueryTokens.expression(ctx.FROM()));
 			}
-			builder.appendInline(visit(ctx.string_expression(0)));
+			nested.append(visit(ctx.string_expression(0)));
+			builder.appendInline(nested);
 			builder.append(TOKEN_CLOSE_PAREN);
 		} else if (ctx.LOWER() != null) {
 
@@ -1932,7 +1928,30 @@ public QueryTokenStream visitFunctions_returning_strings(EqlParser.Functions_ret
 
 			builder.append(QueryTokens.token(ctx.UPPER()));
 			builder.append(TOKEN_OPEN_PAREN);
+			builder.append(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_CLOSE_PAREN);
+		} else if (ctx.LEFT() != null) {
+			builder.append(QueryTokens.token(ctx.LEFT()));
+			builder.append(TOKEN_OPEN_PAREN);
+			builder.appendInline(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_COMMA);
+			builder.appendInline(visit(ctx.arithmetic_expression(0)));
+			builder.append(TOKEN_CLOSE_PAREN);
+		} else if (ctx.RIGHT() != null) {
+			builder.append(QueryTokens.token(ctx.RIGHT()));
+			builder.append(TOKEN_OPEN_PAREN);
+			builder.appendInline(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_COMMA);
+			builder.appendInline(visit(ctx.arithmetic_expression(0)));
+			builder.append(TOKEN_CLOSE_PAREN);
+		} else if (ctx.REPLACE() != null) {
+			builder.append(QueryTokens.token(ctx.REPLACE()));
+			builder.append(TOKEN_OPEN_PAREN);
 			builder.appendInline(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_COMMA);
+			builder.appendInline(visit(ctx.string_expression(1)));
+			builder.append(TOKEN_COMMA);
+			builder.appendInline(visit(ctx.string_expression(2)));
 			builder.append(TOKEN_CLOSE_PAREN);
 		}
 
@@ -1943,11 +1962,11 @@ public QueryTokenStream visitFunctions_returning_strings(EqlParser.Functions_ret
 	public QueryTokenStream visitTrim_specification(EqlParser.Trim_specificationContext ctx) {
 
 		if (ctx.LEADING() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.LEADING()));
+			return QueryTokenStream.ofToken(ctx.LEADING());
 		} else if (ctx.TRAILING() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.TRAILING()));
+			return QueryTokenStream.ofToken(ctx.TRAILING());
 		} else {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.BOTH()));
+			return QueryTokenStream.ofToken(ctx.BOTH());
 		}
 	}
 
@@ -1960,9 +1979,9 @@ public QueryTokenStream visitCast_function(EqlParser.Cast_functionContext ctx) {
 		builder.append(TOKEN_OPEN_PAREN);
 		builder.appendInline(visit(ctx.single_valued_path_expression()));
 		builder.append(TOKEN_SPACE);
-		builder.appendInline(visit(ctx.identification_variable()));
+		builder.appendInline(QueryTokenStream.concat(ctx.identification_variable(), this::visit, TOKEN_SPACE));
 
-		if (ctx.numeric_literal() != null) {
+		if (!ObjectUtils.isEmpty(ctx.numeric_literal())) {
 
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.appendInline(QueryTokenStream.concat(ctx.numeric_literal(), this::visit, TOKEN_COMMA));
@@ -1999,12 +2018,15 @@ public QueryTokenStream visitFunction_invocation(EqlParser.Function_invocationCo
 	public QueryTokenStream visitExtract_datetime_field(EqlParser.Extract_datetime_fieldContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
+		QueryRendererBuilder nested = QueryRenderer.builder();
+
+		nested.appendExpression(visit(ctx.datetime_field()));
+		nested.append(QueryTokens.expression(ctx.FROM()));
+		nested.appendExpression(visit(ctx.datetime_expression()));
 
 		builder.append(QueryTokens.token(ctx.EXTRACT()));
 		builder.append(TOKEN_OPEN_PAREN);
-		builder.appendExpression(visit(ctx.datetime_field()));
-		builder.append(QueryTokens.expression(ctx.FROM()));
-		builder.appendInline(visit(ctx.datetime_expression()));
+		builder.appendInline(nested);
 		builder.append(TOKEN_CLOSE_PAREN);
 
 		return builder;
@@ -2019,12 +2041,15 @@ public QueryTokenStream visitDatetime_field(EqlParser.Datetime_fieldContext ctx)
 	public QueryTokenStream visitExtract_datetime_part(EqlParser.Extract_datetime_partContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
+		QueryRendererBuilder nested = QueryRenderer.builder();
+
+		nested.appendExpression(visit(ctx.datetime_part()));
+		nested.append(QueryTokens.expression(ctx.FROM()));
+		nested.appendExpression(visit(ctx.datetime_expression()));
 
 		builder.append(QueryTokens.token(ctx.EXTRACT()));
 		builder.append(TOKEN_OPEN_PAREN);
-		builder.appendExpression(visit(ctx.datetime_part()));
-		builder.append(QueryTokens.expression(ctx.FROM()));
-		builder.appendInline(visit(ctx.datetime_expression()));
+		builder.appendInline(nested);
 		builder.append(TOKEN_CLOSE_PAREN);
 
 		return builder;
@@ -2063,16 +2088,21 @@ public QueryTokenStream visitCase_expression(EqlParser.Case_expressionContext ct
 		}
 	}
 
+	@Override
+	public QueryRendererBuilder visitType_literal(EqlParser.Type_literalContext ctx) {
+
+		QueryRendererBuilder builder = QueryRenderer.builder();
+		ctx.children.forEach(it -> builder.append(QueryTokens.expression(it.getText())));
+		return builder;
+	}
+
 	@Override
 	public QueryTokenStream visitGeneral_case_expression(EqlParser.General_case_expressionContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
 		builder.append(QueryTokens.expression(ctx.CASE()));
-
-		ctx.when_clause().forEach(whenClauseContext -> {
-			builder.appendExpression(visit(whenClauseContext));
-		});
+		builder.appendExpression(QueryTokenStream.concat(ctx.when_clause(), this::visit, TOKEN_SPACE));
 
 		builder.append(QueryTokens.expression(ctx.ELSE()));
 		builder.appendExpression(visit(ctx.scalar_expression()));
@@ -2087,9 +2117,9 @@ public QueryTokenStream visitWhen_clause(EqlParser.When_clauseContext ctx) {
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
 		builder.append(QueryTokens.expression(ctx.WHEN()));
-		builder.append(visit(ctx.conditional_expression()));
+		builder.appendExpression(visit(ctx.conditional_expression()));
 		builder.append(QueryTokens.expression(ctx.THEN()));
-		builder.append(visit(ctx.scalar_expression()));
+		builder.appendExpression(visit(ctx.scalar_expression()));
 
 		return builder;
 	}
@@ -2100,14 +2130,11 @@ public QueryTokenStream visitSimple_case_expression(EqlParser.Simple_case_expres
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
 		builder.append(QueryTokens.expression(ctx.CASE()));
-		builder.append(visit(ctx.case_operand()));
-
-		ctx.simple_when_clause().forEach(simpleWhenClauseContext -> {
-			builder.append(visit(simpleWhenClauseContext));
-		});
+		builder.appendExpression(visit(ctx.case_operand()));
+		builder.appendExpression(QueryTokenStream.concat(ctx.simple_when_clause(), this::visit, TOKEN_SPACE));
 
 		builder.append(QueryTokens.expression(ctx.ELSE()));
-		builder.append(visit(ctx.scalar_expression()));
+		builder.appendExpression(visit(ctx.scalar_expression()));
 		builder.append(QueryTokens.expression(ctx.END()));
 
 		return builder;
@@ -2168,11 +2195,11 @@ public QueryTokenStream visitNullif_expression(EqlParser.Nullif_expressionContex
 	public QueryTokenStream visitTrim_character(EqlParser.Trim_characterContext ctx) {
 
 		if (ctx.CHARACTER() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.CHARACTER()));
+			return QueryTokenStream.ofToken(ctx.CHARACTER());
 		} else if (ctx.character_valued_input_parameter() != null) {
 			return visit(ctx.character_valued_input_parameter());
 		} else {
-			return QueryRenderer.builder();
+			return QueryTokenStream.empty();
 		}
 	}
 
@@ -2180,11 +2207,13 @@ public QueryTokenStream visitTrim_character(EqlParser.Trim_characterContext ctx)
 	public QueryTokenStream visitIdentification_variable(EqlParser.Identification_variableContext ctx) {
 
 		if (ctx.IDENTIFICATION_VARIABLE() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.IDENTIFICATION_VARIABLE()));
+			return QueryTokenStream.ofToken(ctx.IDENTIFICATION_VARIABLE());
+		} else if (ctx.type_literal() != null) {
+			return visit(ctx.type_literal());
 		} else if (ctx.f != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.f));
+			return QueryTokenStream.ofToken(ctx.f);
 		} else {
-			return QueryRenderer.builder();
+			return QueryTokenStream.empty();
 		}
 	}
 
@@ -2196,23 +2225,23 @@ public QueryTokenStream visitConstructor_name(EqlParser.Constructor_nameContext
 	@Override
 	public QueryTokenStream visitLiteral(EqlParser.LiteralContext ctx) {
 
-		QueryRendererBuilder builder = QueryRenderer.builder();
-
 		if (ctx.STRINGLITERAL() != null) {
-			builder.append(QueryTokens.expression(ctx.STRINGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.STRINGLITERAL());
+		} else if (ctx.JAVASTRINGLITERAL() != null) {
+			return QueryTokenStream.ofToken(ctx.JAVASTRINGLITERAL());
 		} else if (ctx.INTLITERAL() != null) {
-			builder.append(QueryTokens.expression(ctx.INTLITERAL()));
+			return QueryTokenStream.ofToken(ctx.INTLITERAL());
 		} else if (ctx.FLOATLITERAL() != null) {
-			builder.append(QueryTokens.expression(ctx.FLOATLITERAL()));
+			return QueryTokenStream.ofToken(ctx.FLOATLITERAL());
 		} else if (ctx.LONGLITERAL() != null) {
-			builder.append(QueryTokens.expression(ctx.LONGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.LONGLITERAL());
 		} else if (ctx.boolean_literal() != null) {
-			builder.append(visit(ctx.boolean_literal()));
+			return visit(ctx.boolean_literal());
 		} else if (ctx.entity_type_literal() != null) {
-			builder.append(visit(ctx.entity_type_literal()));
+			return visit(ctx.entity_type_literal());
 		}
 
-		return builder;
+		return QueryTokenStream.empty();
 	}
 
 	@Override
@@ -2247,13 +2276,13 @@ public QueryTokenStream visitPattern_value(EqlParser.Pattern_valueContext ctx) {
 	public QueryTokenStream visitDate_time_timestamp_literal(EqlParser.Date_time_timestamp_literalContext ctx) {
 
 		if (ctx.STRINGLITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.STRINGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.STRINGLITERAL());
 		} else if (ctx.DATELITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.DATELITERAL()));
+			return QueryTokenStream.ofToken(ctx.DATELITERAL());
 		} else if (ctx.TIMELITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.TIMELITERAL()));
+			return QueryTokenStream.ofToken(ctx.TIMELITERAL());
 		} else if (ctx.TIMESTAMPLITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.TIMESTAMPLITERAL()));
+			return QueryTokenStream.ofToken(ctx.TIMESTAMPLITERAL());
 		} else {
 			return QueryRenderer.builder();
 		}
@@ -2266,20 +2295,20 @@ public QueryTokenStream visitEntity_type_literal(EqlParser.Entity_type_literalCo
 
 	@Override
 	public QueryTokenStream visitEscape_character(EqlParser.Escape_characterContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.CHARACTER()));
+		return QueryTokenStream.ofToken(ctx.CHARACTER());
 	}
 
 	@Override
 	public QueryTokenStream visitNumeric_literal(EqlParser.Numeric_literalContext ctx) {
 
 		if (ctx.INTLITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.INTLITERAL()));
+			return QueryTokenStream.ofToken(ctx.INTLITERAL());
 		} else if (ctx.FLOATLITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.FLOATLITERAL()));
+			return QueryTokenStream.ofToken(ctx.FLOATLITERAL());
 		} else if (ctx.LONGLITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.LONGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.LONGLITERAL());
 		} else {
-			return QueryRenderer.builder();
+			return QueryTokenStream.empty();
 		}
 	}
 
@@ -2287,11 +2316,11 @@ public QueryTokenStream visitNumeric_literal(EqlParser.Numeric_literalContext ct
 	public QueryTokenStream visitBoolean_literal(EqlParser.Boolean_literalContext ctx) {
 
 		if (ctx.TRUE() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.TRUE()));
+			return QueryTokenStream.ofToken(ctx.TRUE());
 		} else if (ctx.FALSE() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.FALSE()));
+			return QueryTokenStream.ofToken(ctx.FALSE());
 		} else {
-			return QueryRenderer.builder();
+			return QueryTokenStream.empty();
 		}
 	}
 
@@ -2304,11 +2333,11 @@ public QueryTokenStream visitEnum_literal(EqlParser.Enum_literalContext ctx) {
 	public QueryTokenStream visitString_literal(EqlParser.String_literalContext ctx) {
 
 		if (ctx.CHARACTER() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.CHARACTER()));
+			return QueryTokenStream.ofToken(ctx.CHARACTER());
 		} else if (ctx.STRINGLITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.STRINGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.STRINGLITERAL());
 		} else {
-			return QueryRenderer.builder();
+			return QueryTokenStream.empty();
 		}
 	}
 
@@ -2365,7 +2394,7 @@ public QueryTokenStream visitCollection_value_field(EqlParser.Collection_value_f
 
 	@Override
 	public QueryTokenStream visitEntity_name(EqlParser.Entity_nameContext ctx) {
-		return QueryTokenStream.concat(ctx.reserved_word(), this::visit, QueryRenderer::inline, TOKEN_DOT);
+		return QueryTokenStream.concat(ctx.reserved_word(), this::visit, TOKEN_DOT);
 	}
 
 	@Override
@@ -2396,26 +2425,25 @@ public QueryTokenStream visitFunction_name(EqlParser.Function_nameContext ctx) {
 	}
 
 	@Override
-	public QueryTokenStream visitCharacter_valued_input_parameter(
-			EqlParser.Character_valued_input_parameterContext ctx) {
+	public QueryTokenStream visitCharacter_valued_input_parameter(EqlParser.Character_valued_input_parameterContext ctx) {
 
 		if (ctx.CHARACTER() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.CHARACTER()));
+			return QueryTokenStream.ofToken(ctx.CHARACTER());
 		} else if (ctx.input_parameter() != null) {
 			return visit(ctx.input_parameter());
 		} else {
-			return QueryRenderer.builder();
+			return QueryTokenStream.empty();
 		}
 	}
 
 	@Override
 	public QueryTokenStream visitReserved_word(EqlParser.Reserved_wordContext ctx) {
 		if (ctx.IDENTIFICATION_VARIABLE() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.IDENTIFICATION_VARIABLE()));
+			return QueryTokenStream.ofToken(ctx.IDENTIFICATION_VARIABLE());
 		} else if (ctx.f != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.f));
+			return QueryTokenStream.ofToken(ctx.f);
 		} else {
-			return QueryRenderer.builder();
+			return QueryTokenStream.empty();
 		}
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java
index e544024750..30e9106d22 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java
@@ -20,11 +20,11 @@
 import java.util.List;
 
 import org.springframework.data.domain.Sort;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
 import org.springframework.data.repository.query.ReturnedType;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
-import org.springframework.util.ObjectUtils;
 
 /**
  * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed EQL query by applying
@@ -54,7 +54,7 @@ class EqlSortedQueryTransformer extends EqlQueryRenderer {
 	}
 
 	@Override
-	public QueryRendererBuilder visitSelect_statement(EqlParser.Select_statementContext ctx) {
+	public QueryTokenStream visitSelect_statement(EqlParser.Select_statementContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
@@ -73,12 +73,10 @@ public QueryRendererBuilder visitSelect_statement(EqlParser.Select_statementCont
 			builder.appendExpression(visit(ctx.having_clause()));
 		}
 
-		doVisitOrderBy(builder, ctx, ObjectUtils.isEmpty(ctx.setOperator()) ? this.sort : Sort.unsorted());
-
-		for (int i = 0; i < ctx.setOperator().size(); i++) {
-
-			builder.appendExpression(visit(ctx.setOperator(i)));
-			builder.appendExpression(visit(ctx.select_statement(i)));
+		if (ctx.set_fuction() != null) {
+			builder.appendExpression(visit(ctx.set_fuction()));
+		} else {
+			doVisitOrderBy(builder, ctx);
 		}
 
 		return builder;
@@ -104,7 +102,7 @@ public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) {
 		return builder.append(dtoDelegate.transformSelectionList(tokenStream));
 	}
 
-	private void doVisitOrderBy(QueryRendererBuilder builder, EqlParser.Select_statementContext ctx, Sort sort) {
+	private void doVisitOrderBy(QueryRendererBuilder builder, EqlParser.Select_statementContext ctx) {
 
 		if (ctx.orderby_clause() != null) {
 			QueryTokenStream existingOrder = visit(ctx.orderby_clause());
@@ -137,7 +135,7 @@ public QueryTokenStream visitSelect_item(EqlParser.Select_itemContext ctx) {
 		QueryTokenStream tokens = super.visitSelect_item(ctx);
 
 		if (ctx.result_variable() != null && !tokens.isEmpty()) {
-			transformerSupport.registerAlias(tokens.getLast());
+			transformerSupport.registerAlias(tokens.getRequiredLast());
 		}
 
 		return tokens;
@@ -149,7 +147,7 @@ public QueryTokenStream visitJoin(EqlParser.JoinContext ctx) {
 		QueryTokenStream tokens = super.visitJoin(ctx);
 
 		if (!tokens.isEmpty()) {
-			transformerSupport.registerAlias(tokens.getLast());
+			transformerSupport.registerAlias(tokens.getRequiredLast());
 		}
 
 		return tokens;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EscapeCharacter.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EscapeCharacter.java
index d73680ff62..d6ef5c321b 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EscapeCharacter.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EscapeCharacter.java
@@ -19,7 +19,8 @@
 import java.util.List;
 import java.util.stream.Stream;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
+import org.springframework.lang.Contract;
 
 /**
  * A value type encapsulating an escape character for LIKE queries and the actually usage of it in escaping
@@ -49,8 +50,8 @@ public static EscapeCharacter of(char escapeCharacter) {
 	 * @param value may be {@literal null}.
 	 * @return
 	 */
-	@Nullable
-	public String escape(@Nullable String value) {
+	@Contract("null -> null")
+	public @Nullable String escape(@Nullable String value) {
 
 		return value == null //
 				? null //
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessor.java
index 37b06f0744..af1c4fa0ec 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessor.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessor.java
@@ -21,10 +21,11 @@
 import org.hibernate.query.TypedParameterValue;
 import org.hibernate.type.BasicType;
 import org.hibernate.type.BasicTypeRegistry;
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.repository.query.Parameter;
 import org.springframework.data.repository.query.Parameters;
 import org.springframework.data.repository.query.ParametersParameterAccessor;
-import org.springframework.lang.Nullable;
 
 /**
  * {@link org.springframework.data.repository.query.ParameterAccessor} based on an {@link Parameters} instance. In
@@ -51,7 +52,7 @@ class HibernateJpaParametersParameterAccessor extends JpaParametersParameterAcce
 	 * @param values must not be {@literal null}.
 	 * @param em must not be {@literal null}.
 	 */
-	HibernateJpaParametersParameterAccessor(Parameters<?, ?> parameters, Object[] values, EntityManager em) {
+	HibernateJpaParametersParameterAccessor(JpaParameters parameters, Object[] values, EntityManager em) {
 
 		super(parameters, values);
 
@@ -62,9 +63,8 @@ class HibernateJpaParametersParameterAccessor extends JpaParametersParameterAcce
 	}
 
 	@Override
-	@Nullable
 	@SuppressWarnings("unchecked")
-	public Object getValue(Parameter parameter) {
+	public @Nullable Object getValue(Parameter parameter) {
 
 		Object value = super.getValue(parameter.getIndex());
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateQueryInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateQueryInformation.java
index 755dade914..405fa08660 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateQueryInformation.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateQueryInformation.java
@@ -17,7 +17,7 @@
 
 import java.util.List;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Hibernate-specific query details capturing common table expression details.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlCountQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlCountQueryTransformer.java
index ed66a41ad9..b048b6601d 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlCountQueryTransformer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlCountQueryTransformer.java
@@ -18,9 +18,10 @@
 import static org.springframework.data.jpa.repository.query.QueryTokens.*;
 
 import org.springframework.data.jpa.repository.query.HqlParser.SelectClauseContext;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
 import org.springframework.data.jpa.repository.query.QueryTransformers.CountSelectionTokenStream;
-import org.springframework.lang.Nullable;
 
 /**
  * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed HQL query into a
@@ -160,6 +161,7 @@ public QueryRendererBuilder visitJoin(HqlParser.JoinContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
+		builder.append(TOKEN_SPACE);
 		builder.appendExpression(visit(ctx.joinType()));
 		builder.append(QueryTokens.expression(ctx.JOIN()));
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitor.java
new file mode 100644
index 0000000000..1d73d078f3
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitor.java
@@ -0,0 +1,872 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.query;
+
+import static java.time.format.DateTimeFormatter.*;
+
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.Expression;
+import jakarta.persistence.criteria.From;
+import jakarta.persistence.criteria.LocalDateTimeField;
+import jakarta.persistence.criteria.Path;
+import jakarta.persistence.criteria.TemporalField;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.OffsetDateTime;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.Temporal;
+import java.util.Collection;
+import java.util.HexFormat;
+import java.util.Locale;
+import java.util.function.BiFunction;
+
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.tree.ParseTree;
+import org.antlr.v4.runtime.tree.TerminalNode;
+import org.hibernate.query.criteria.HibernateCriteriaBuilder;
+
+import org.jspecify.annotations.Nullable;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.JpaSort;
+import org.springframework.data.mapping.PropertyPath;
+import org.springframework.util.Assert;
+
+/**
+ * Parses the content of {@link JpaSort#unsafe(String...)} as an HQL {@literal sortExpression} and renders that into a
+ * JPA Criteria {@link Expression}.
+ *
+ * @author Greg Turnquist
+ * @author Mark Paluch
+ * @since 4.0
+ */
+@SuppressWarnings({ "unchecked", "rawtypes", "ConstantValue", "NullAway" })
+class HqlOrderExpressionVisitor extends HqlBaseVisitor<Expression<?>> {
+
+	private static final DateTimeFormatter DATE_TIME = new DateTimeFormatterBuilder().parseCaseInsensitive()
+			.append(ISO_LOCAL_DATE).optionalStart().appendLiteral(' ').optionalEnd().optionalStart().appendLiteral('T')
+			.optionalEnd().append(ISO_LOCAL_TIME).optionalStart().appendLiteral(' ').optionalEnd().optionalStart()
+			.appendZoneOrOffsetId().optionalEnd().toFormatter();
+
+	private static final DateTimeFormatter DATE_TIME_FORMATTER_DATE = DateTimeFormatter.ofPattern("yyyy-MM-dd",
+			Locale.ENGLISH);
+
+	private static final DateTimeFormatter DATE_TIME_FORMATTER_TIME = DateTimeFormatter.ofPattern("HH:mm:ss",
+			Locale.ENGLISH);
+
+	private static final String UNSUPPORTED_TEMPLATE = "We can't handle %s in an ORDER BY clause through JpaSort.unsafe(…)";
+
+	private final CriteriaBuilder cb;
+	private final Path<?> from;
+	private final BiFunction<From<?, ?>, PropertyPath, Expression<?>> expressionFactory;
+
+	/**
+	 * @param cb criteria builder.
+	 * @param from from path (i.e. root entity).
+	 * @param expressionFactory factory to create expressions such as
+	 *          {@link QueryUtils#toExpressionRecursively(From, PropertyPath)}.
+	 */
+	HqlOrderExpressionVisitor(CriteriaBuilder cb, Path<?> from,
+			BiFunction<From<?, ?>, PropertyPath, Expression<?>> expressionFactory) {
+		this.cb = cb;
+		this.from = from;
+		this.expressionFactory = expressionFactory;
+	}
+
+	/**
+	 * Extract the {@link org.springframework.data.jpa.domain.JpaSort.JpaOrder}'s property and parse it as an HQL
+	 * {@literal sortExpression}.
+	 *
+	 * @param jpaOrder must not be {@literal null}.
+	 * @return criteriaExpression
+	 * @throws IllegalArgumentException thrown if the order yields no sort expression.
+	 * @throws UnsupportedOperationException thrown if the order contains an unsupported expression.
+	 * @throws BadJpqlGrammarException thrown if the order contains a syntax errors.
+	 */
+	Expression<?> createCriteriaExpression(Sort.Order jpaOrder) {
+
+		String orderByProperty = jpaOrder.getProperty();
+		HqlLexer lexer = new HqlLexer(CharStreams.fromString(orderByProperty));
+		HqlParser parser = new HqlParser(new CommonTokenStream(lexer));
+
+		JpaQueryEnhancer.configureParser(orderByProperty, "ORDER BY expression", lexer, parser);
+
+		HqlParser.SortExpressionContext ctx = parser.sortExpression();
+
+		if (ctx == null) {
+			throw new IllegalArgumentException("No sort expression provided");
+		}
+
+		return visitRequired(ctx);
+	}
+
+	@Override
+	public @Nullable Expression<?> visitSortExpression(HqlParser.SortExpressionContext ctx) {
+
+		if (ctx.identifier() != null) {
+			HqlParser.IdentifierContext identifier = ctx.identifier();
+
+			return from.get(getString(identifier));
+		} else if (ctx.INTEGER_LITERAL() != null) {
+			return cb.literal(Integer.valueOf(ctx.INTEGER_LITERAL().getText()));
+		} else if (ctx.expression() != null) {
+			return visitRequired(ctx.expression());
+		} else {
+			return null;
+		}
+	}
+
+	@Override
+	public Expression<?> visitRelationalExpression(HqlParser.RelationalExpressionContext ctx) {
+
+		Expression<Comparable> left = visitRequired(ctx.expression(0));
+		Expression<Comparable> right = visitRequired(ctx.expression(1));
+		String op = ctx.op.getText();
+
+		return switch (op) {
+			case "=" -> cb.equal(left, right);
+			case ">" -> cb.greaterThan(left, right);
+			case ">=" -> cb.greaterThanOrEqualTo(left, right);
+			case "<" -> cb.lessThan(left, right);
+			case "<=" -> cb.lessThanOrEqualTo(left, right);
+			case "<>", "!=", "^=" -> cb.notEqual(left, right);
+			default -> throw new UnsupportedOperationException("Unsupported comparison operator: " + op);
+		};
+	}
+
+	@Override
+	public Expression<?> visitBetweenExpression(HqlParser.BetweenExpressionContext ctx) {
+
+		Expression<Comparable> condition = visitRequired(ctx.expression(0));
+		Expression<Comparable> lower = visitRequired(ctx.expression(1));
+		Expression<Comparable> upper = visitRequired(ctx.expression(2));
+
+		if (ctx.NOT() == null) {
+			return cb.between(condition, lower, upper);
+		} else {
+			return cb.between(condition, lower, upper).not();
+		}
+	}
+
+	@SuppressWarnings("unchecked")
+	@Override
+	public Expression<?> visitIsBooleanPredicate(HqlParser.IsBooleanPredicateContext ctx) {
+
+		Expression<?> condition = visitRequired(ctx.expression());
+
+		if (ctx.NULL() != null) {
+			if (ctx.NOT() == null) {
+				return cb.isNull(condition);
+			} else {
+				return cb.isNotNull(condition);
+			}
+		}
+
+		if (ctx.EMPTY() != null) {
+			if (ctx.NOT() == null) {
+				return cb.isEmpty((Expression<? extends Collection<?>>) condition);
+			} else {
+				return cb.isNotEmpty((Expression<? extends Collection<?>>) condition);
+			}
+		}
+
+		if (ctx.TRUE() != null) {
+			if (ctx.NOT() == null) {
+				return cb.isTrue((Expression<Boolean>) condition);
+			} else {
+				return cb.isFalse((Expression<Boolean>) condition);
+			}
+		}
+
+		if (ctx.FALSE() != null) {
+			if (ctx.NOT() == null) {
+				return cb.isFalse((Expression<Boolean>) condition);
+			} else {
+				return cb.isTrue((Expression<Boolean>) condition);
+			}
+		}
+
+		return null;
+	}
+
+	@Override
+	public Expression<?> visitStringPatternMatching(HqlParser.StringPatternMatchingContext ctx) {
+		Expression<String> condition = visitRequired(ctx.expression(0));
+		Expression<String> match = visitRequired(ctx.expression(1));
+		Expression<Character> escape = ctx.ESCAPE() != null ? charLiteralOf(ctx.ESCAPE()) : null;
+
+		if (ctx.LIKE() != null) {
+			if (ctx.NOT() == null) {
+				return escape == null //
+						? cb.like(condition, match) //
+						: cb.like(condition, match, escape);
+			} else {
+				return escape == null //
+						? cb.notLike(condition, match) //
+						: cb.notLike(condition, match, escape);
+			}
+		} else {
+			HibernateCriteriaBuilder hcb = (HibernateCriteriaBuilder) cb;
+			if (ctx.NOT() == null) {
+				return escape == null //
+						? hcb.ilike(condition, match) //
+						: hcb.ilike(condition, match, escape);
+			} else {
+				return escape == null //
+						? hcb.notIlike(condition, match) //
+						: hcb.notIlike(condition, match, escape);
+			}
+		}
+	}
+
+	@Override
+	public Expression<?> visitInExpression(HqlParser.InExpressionContext ctx) {
+
+		if (ctx.inList().simplePath() != null) {
+			throw new UnsupportedOperationException(
+					String.format(UNSUPPORTED_TEMPLATE, "IN clause with ELEMENTS or INDICES argument"));
+		} else if (ctx.inList().subquery() != null) {
+			throw new UnsupportedOperationException(String.format(UNSUPPORTED_TEMPLATE, "IN clause with a subquery"));
+		} else if (ctx.inList().parameter() != null) {
+			throw new UnsupportedOperationException(String.format(UNSUPPORTED_TEMPLATE, "IN clause with a parameter"));
+		}
+
+		CriteriaBuilder.In<Object> in = cb.in(visit(ctx.expression()));
+
+		ctx.inList().expressionOrPredicate()
+				.forEach(expressionOrPredicateContext -> in.value(visit(expressionOrPredicateContext)));
+
+		if (ctx.NOT() == null) {
+			return in;
+		}
+		return in.not();
+
+	}
+
+	@Override
+	public Expression<?> visitGenericFunction(HqlParser.GenericFunctionContext ctx) {
+
+		String functionName = ctx.genericFunctionName().getText();
+
+		if (ctx.genericFunctionArguments() == null) {
+			return cb.function(functionName, Object.class);
+		}
+
+		Expression<?>[] arguments = ctx.genericFunctionArguments().expressionOrPredicate().stream() //
+				.map(this::visitRequired) //
+				.toArray(Expression[]::new);
+		return cb.function(functionName, Object.class, arguments);
+
+	}
+
+	@Override
+	public Expression<?> visitCastFunction(HqlParser.CastFunctionContext ctx) {
+		throw new UnsupportedOperationException("Sorting using CAST ist not supported");
+	}
+
+	@Override
+	public Expression<?> visitTreatedNavigablePath(HqlParser.TreatedNavigablePathContext ctx) {
+		throw new UnsupportedOperationException("Sorting using TREAT ist not supported");
+	}
+
+	@Override
+	@SuppressWarnings({ "rawtypes", "unchecked" })
+	public Expression<?> visitExtractFunction(HqlParser.ExtractFunctionContext ctx) {
+
+		Expression expr = visitRequired(ctx.expression());
+		TemporalField temporalField = ctx.extractField() != null ? getTemporalField(ctx.extractField())
+				: getTemporalField(ctx.datetimeField());
+
+		return cb.extract(temporalField, expr);
+	}
+
+	private TemporalField<?, ? extends Temporal> getTemporalField(HqlParser.DatetimeFieldContext ctx) {
+
+		if (ctx.YEAR() != null) {
+			return LocalDateTimeField.YEAR;
+		}
+
+		if (ctx.MONTH() != null) {
+			return LocalDateTimeField.MONTH;
+		}
+
+		if (ctx.QUARTER() != null) {
+			return LocalDateTimeField.QUARTER;
+		}
+
+		if (ctx.WEEK() != null) {
+			return LocalDateTimeField.WEEK;
+		}
+
+		if (ctx.DAY() != null) {
+			return LocalDateTimeField.DAY;
+		}
+
+		if (ctx.HOUR() != null) {
+			return LocalDateTimeField.HOUR;
+		}
+
+		if (ctx.MINUTE() != null) {
+			return LocalDateTimeField.MINUTE;
+		}
+
+		if (ctx.SECOND() != null) {
+			return LocalDateTimeField.SECOND;
+		}
+
+		throw new UnsupportedOperationException("Unsupported extract field: " + ctx.getText());
+	}
+
+	private TemporalField<?, ? extends Temporal> getTemporalField(HqlParser.ExtractFieldContext ctx) {
+
+		if (ctx.dateOrTimeField() != null) {
+
+			if (ctx.dateOrTimeField().DATE() != null) {
+				return LocalDateTimeField.DATE;
+			}
+
+			if (ctx.dateOrTimeField().TIME() != null) {
+				return LocalDateTimeField.DATE;
+			}
+		} else if (ctx.datetimeField() != null) {
+
+			if (ctx.datetimeField().YEAR() != null) {
+				return LocalDateTimeField.YEAR;
+			}
+
+			if (ctx.datetimeField().MONTH() != null) {
+				return LocalDateTimeField.MONTH;
+			}
+
+			if (ctx.datetimeField().QUARTER() != null) {
+				return LocalDateTimeField.QUARTER;
+			}
+
+			if (ctx.datetimeField().WEEK() != null) {
+				return LocalDateTimeField.WEEK;
+			}
+
+			if (ctx.datetimeField().DAY() != null) {
+				return LocalDateTimeField.DAY;
+			}
+
+			if (ctx.datetimeField().HOUR() != null) {
+				return LocalDateTimeField.HOUR;
+			}
+
+			if (ctx.datetimeField().MINUTE() != null) {
+				return LocalDateTimeField.MINUTE;
+			}
+
+			if (ctx.datetimeField().SECOND() != null) {
+				return LocalDateTimeField.SECOND;
+			}
+		} else if (ctx.weekField() != null) {
+
+			if (ctx.weekField().WEEK() != null) {
+				return LocalDateTimeField.WEEK;
+			}
+
+			if (ctx.weekField().MONTH() != null) {
+				return LocalDateTimeField.MONTH;
+			}
+
+			if (ctx.weekField().YEAR() != null) {
+				return LocalDateTimeField.YEAR;
+			}
+		}
+
+		throw new UnsupportedOperationException("Unsupported extract field: " + ctx.getText());
+	}
+
+	@Override
+	public Expression<?> visitTruncFunction(HqlParser.TruncFunctionContext ctx) {
+
+		Expression expr = visitRequired(ctx.expression().get(0));
+
+		if (ctx.datetimeField() != null) {
+			TemporalField temporalField = getTemporalField(ctx.datetimeField());
+
+			return cb.function("trunc", Object.class, expr, cb.literal(temporalField));
+		} else if (ctx.expression().size() > 1) {
+
+			return cb.function("trunc", Object.class, expr, visitRequired(ctx.expression().get(1)));
+		}
+
+		return cb.function("trunc", Object.class, expr);
+	}
+
+	@Override
+	public Expression<?> visitTrimFunction(HqlParser.TrimFunctionContext ctx) {
+
+		CriteriaBuilder.Trimspec trimSpec = null;
+
+		HqlParser.TrimSpecificationContext tsc = ctx.trimSpecification();
+
+		if (tsc.LEADING() != null) {
+			trimSpec = CriteriaBuilder.Trimspec.LEADING;
+		} else if (tsc.TRAILING() != null) {
+			trimSpec = CriteriaBuilder.Trimspec.TRAILING;
+		} else if (tsc.BOTH() != null) {
+			trimSpec = CriteriaBuilder.Trimspec.BOTH;
+		}
+
+		Expression<Character> stringLiteral = charLiteralOf(ctx.trimCharacter().STRING_LITERAL());
+		Expression<String> expression = visitRequired(ctx.expression());
+
+		if (trimSpec != null) {
+			return stringLiteral != null //
+					? cb.trim(trimSpec, stringLiteral, expression) //
+					: cb.trim(trimSpec, expression);
+		} else {
+			return stringLiteral != null //
+					? cb.trim(stringLiteral, expression) //
+					: cb.trim(expression);
+		}
+	}
+
+	@Override
+	public Expression<?> visitSubstringFunction(HqlParser.SubstringFunctionContext ctx) {
+
+		Expression<Integer> start = visitRequired(ctx.substringFunctionStartArgument().expression());
+
+		if (ctx.substringFunctionLengthArgument() != null) {
+			Expression<Integer> length = visitRequired(ctx.substringFunctionLengthArgument().expression());
+			return cb.substring(visitRequired(ctx.expression()), start, length);
+		}
+
+		return cb.substring(visitRequired(ctx.expression()), start);
+	}
+
+	@Override
+	public Expression<?> visitLiteral(HqlParser.LiteralContext ctx) {
+
+		if (ctx.booleanLiteral() != null) {
+			return visitRequired(ctx.booleanLiteral());
+		} else if (ctx.JAVA_STRING_LITERAL() != null) {
+			return literalOf(ctx.JAVA_STRING_LITERAL());
+		} else if (ctx.STRING_LITERAL() != null) {
+			return literalOf(ctx.STRING_LITERAL());
+		} else if (ctx.numericLiteral() != null) {
+			return visitRequired(ctx.numericLiteral());
+		} else if (ctx.temporalLiteral() != null) {
+			return visitRequired(ctx.temporalLiteral());
+		} else if (ctx.binaryLiteral() != null) {
+			return visitRequired(ctx.binaryLiteral());
+		} else {
+			return null;
+		}
+	}
+
+	private Expression<String> literalOf(TerminalNode node) {
+
+		String text = node.getText();
+		return cb.literal(unquoteStringLiteral(text));
+	}
+
+	private Expression<Character> charLiteralOf(TerminalNode node) {
+
+		String text = node.getText();
+		return cb.literal(text.charAt(0));
+	}
+
+	@Override
+	public Expression<?> visitBooleanLiteral(HqlParser.BooleanLiteralContext ctx) {
+		if (ctx.TRUE() != null) {
+			return cb.literal(true);
+		} else {
+			return cb.literal(false);
+		}
+	}
+
+	@Override
+	public Expression<?> visitNumericLiteral(HqlParser.NumericLiteralContext ctx) {
+		return cb.literal(getLiteralValue(ctx));
+	}
+
+	private Number getLiteralValue(HqlParser.NumericLiteralContext ctx) {
+
+		if (ctx.INTEGER_LITERAL() != null) {
+			return Integer.valueOf(getDecimals(ctx.INTEGER_LITERAL()));
+		} else if (ctx.LONG_LITERAL() != null) {
+			return Long.valueOf(getDecimals(ctx.LONG_LITERAL()));
+		} else if (ctx.FLOAT_LITERAL() != null) {
+			return Float.valueOf(getDecimals(ctx.FLOAT_LITERAL()));
+		} else if (ctx.DOUBLE_LITERAL() != null) {
+			return Double.valueOf(getDecimals(ctx.DOUBLE_LITERAL()));
+		} else if (ctx.BIG_INTEGER_LITERAL() != null) {
+			return new BigInteger(getDecimals(ctx.BIG_INTEGER_LITERAL()));
+		} else if (ctx.BIG_DECIMAL_LITERAL() != null) {
+			return new BigDecimal(getDecimals(ctx.BIG_DECIMAL_LITERAL()));
+		} else if (ctx.HEX_LITERAL() != null) {
+			return HexFormat.fromHexDigits(ctx.HEX_LITERAL().toString().substring(2));
+		}
+
+		throw new UnsupportedOperationException("Unsupported literal: " + ctx.getText());
+	}
+
+	@Override
+	public Expression<?> visitDateTimeLiteral(HqlParser.DateTimeLiteralContext ctx) {
+
+		if (ctx.offsetDateTimeLiteral() != null) {
+			return visit(ctx.offsetDateTimeLiteral());
+		} else if (ctx.localDateTimeLiteral() != null) {
+			return visit(ctx.localDateTimeLiteral());
+		} else if (ctx.zonedDateTimeLiteral() != null) {
+			return visit(ctx.zonedDateTimeLiteral());
+		}
+
+		return null;
+	}
+
+	@Override
+	public Expression<?> visitJdbcTimeLiteral(HqlParser.JdbcTimeLiteralContext ctx) {
+
+		if (ctx.time() != null) {
+			return visitRequired(ctx.time());
+		}
+
+		return cb.literal(DATE_TIME_FORMATTER_TIME.parse(unquoteTemporal(ctx.genericTemporalLiteralText())));
+	}
+
+	@Override
+	public Expression<?> visitDate(HqlParser.DateContext ctx) {
+		return cb.literal(LocalDate.from(DATE_TIME_FORMATTER_DATE.parse(unquoteTemporal(ctx))));
+	}
+
+	@Override
+	public Expression<?> visitTime(HqlParser.TimeContext ctx) {
+		return cb.literal(LocalTime.from(DATE_TIME_FORMATTER_TIME.parse(unquoteTemporal(ctx))));
+	}
+
+	@Override
+	public Expression<?> visitJdbcDateLiteral(HqlParser.JdbcDateLiteralContext ctx) {
+
+		if (ctx.date() != null) {
+			return visitRequired(ctx.date());
+		}
+
+		return cb
+				.literal(LocalDate.from(DATE_TIME_FORMATTER_DATE.parse(unquoteTemporal(ctx.genericTemporalLiteralText()))));
+	}
+
+	@Override
+	public Expression<?> visitJdbcTimestampLiteral(HqlParser.JdbcTimestampLiteralContext ctx) {
+
+		if (ctx.dateTime() != null) {
+			return visitRequired(ctx.dateTime());
+		}
+
+		return cb.literal(LocalDateTime.from(DATE_TIME.parse(unquoteTemporal(ctx.genericTemporalLiteralText()))));
+	}
+
+	@Override
+	public Expression<?> visitLocalDateTime(HqlParser.LocalDateTimeContext ctx) {
+		return cb.literal(LocalDateTime.from(DATE_TIME.parse(unquoteTemporal(ctx.getText()))));
+	}
+
+	@Override
+	public Expression<?> visitZonedDateTime(HqlParser.ZonedDateTimeContext ctx) {
+		return cb.literal(ZonedDateTime.parse(ctx.getText()));
+	}
+
+	@Override
+	public Expression<?> visitOffsetDateTime(HqlParser.OffsetDateTimeContext ctx) {
+		return cb.literal(OffsetDateTime.parse(ctx.getText()));
+	}
+
+	@Override
+	public Expression<?> visitOffsetDateTimeWithMinutes(HqlParser.OffsetDateTimeWithMinutesContext ctx) {
+		return cb.literal(OffsetDateTime.parse(ctx.getText()));
+	}
+
+	@Override
+	public Expression<?> visitLocalDateTimeLiteral(HqlParser.LocalDateTimeLiteralContext ctx) {
+		return visitRequired(ctx.localDateTime());
+	}
+
+	@Override
+	public Expression<?> visitZonedDateTimeLiteral(HqlParser.ZonedDateTimeLiteralContext ctx) {
+		return visitRequired(ctx.zonedDateTime());
+	}
+
+	@Override
+	public Expression<?> visitOffsetDateTimeLiteral(HqlParser.OffsetDateTimeLiteralContext ctx) {
+		return visitRequired(ctx.offsetDateTime() != null ? ctx.offsetDateTime() : ctx.offsetDateTimeWithMinutes());
+	}
+
+	@Override
+	public Expression<?> visitDateLiteral(HqlParser.DateLiteralContext ctx) {
+		return visitRequired(ctx.date());
+	}
+
+	@Override
+	public Expression<?> visitTimeLiteral(HqlParser.TimeLiteralContext ctx) {
+		return visitRequired(ctx.time());
+	}
+
+	@Override
+	public Expression<?> visitDateTime(HqlParser.DateTimeContext ctx) {
+		return super.visitDateTime(ctx);
+	}
+
+	@Override
+	public Expression<?> visitGroupedExpression(HqlParser.GroupedExpressionContext ctx) {
+		return visit(ctx.expression());
+	}
+
+	@Override
+	public Expression<?> visitTupleExpression(HqlParser.TupleExpressionContext ctx) {
+		return (Expression<?>) cb
+				.tuple(ctx.expressionOrPredicate().stream().map(this::visitRequired).toArray(Expression[]::new));
+	}
+
+	@Override
+	public Expression<?> visitSubqueryExpression(HqlParser.SubqueryExpressionContext ctx) {
+		throw new UnsupportedOperationException(String.format(UNSUPPORTED_TEMPLATE, "a subquery argument"));
+	}
+
+	@Override
+	public Expression<?> visitMultiplicationExpression(HqlParser.MultiplicationExpressionContext ctx) {
+
+		Expression<Number> left = visitRequired(ctx.expression(0));
+		Expression<Number> right = visitRequired(ctx.expression(1));
+
+		if (ctx.op.getText().equals("*")) {
+			return cb.prod(left, right);
+		} else {
+			return cb.quot(left, right);
+		}
+	}
+
+	@Override
+	public Expression<?> visitAdditionExpression(HqlParser.AdditionExpressionContext ctx) {
+
+		Expression<Number> left = visitRequired(ctx.expression(0));
+		Expression<Number> right = visitRequired(ctx.expression(1));
+
+		if (ctx.op.getText().equals("+")) {
+			return cb.sum(left, right);
+		} else {
+			return cb.diff(left, right);
+		}
+	}
+
+	@Override
+	public Expression<?> visitHqlConcatenationExpression(HqlParser.HqlConcatenationExpressionContext ctx) {
+
+		Expression<String> left = visitRequired(ctx.expression(0));
+		Expression<String> right = visitRequired(ctx.expression(1));
+
+		return cb.concat(left, right);
+	}
+
+	@Override
+	public Expression<?> visitSimplePath(HqlParser.SimplePathContext ctx) {
+		return expressionFactory.apply((From<?, ?>) from, PropertyPath.from(ctx.getText(), from.getJavaType()));
+	}
+
+	@Override
+	public Expression<?> visitCaseList(HqlParser.CaseListContext ctx) {
+		return visit(ctx.simpleCaseExpression() != null ? ctx.simpleCaseExpression() : ctx.searchedCaseExpression());
+	}
+
+	@Override
+	public Expression<?> visitSimpleCaseExpression(HqlParser.SimpleCaseExpressionContext ctx) {
+
+		CriteriaBuilder.SimpleCase<Object, Object> simpleCase = cb.selectCase(visit(ctx.expressionOrPredicate(0)));
+
+		ctx.caseWhenExpressionClause().forEach(caseWhenExpressionClauseContext -> {
+			simpleCase.when( //
+					visitRequired(caseWhenExpressionClauseContext.expression()), //
+					visitRequired(caseWhenExpressionClauseContext.expressionOrPredicate()));
+		});
+
+		if (ctx.expressionOrPredicate().size() == 2) {
+			simpleCase.otherwise(visitRequired(ctx.expressionOrPredicate(1)));
+		}
+
+		return simpleCase;
+	}
+
+	@Override
+	public Expression<?> visitSearchedCaseExpression(HqlParser.SearchedCaseExpressionContext ctx) {
+
+		CriteriaBuilder.Case<Object> searchedCase = cb.selectCase();
+
+		ctx.caseWhenPredicateClause().forEach(caseWhenPredicateClauseContext -> {
+			searchedCase.when( //
+					visitRequired(caseWhenPredicateClauseContext.predicate()), //
+					visit(caseWhenPredicateClauseContext.expressionOrPredicate()));
+		});
+
+		if (ctx.expressionOrPredicate() != null) {
+			searchedCase.otherwise(visit(ctx.expressionOrPredicate()));
+		}
+
+		return searchedCase;
+	}
+
+	@Override
+	public Expression<?> visitParameter(HqlParser.ParameterContext ctx) {
+		throw new UnsupportedOperationException(String.format(UNSUPPORTED_TEMPLATE, "a parameter argument"));
+	}
+
+	private <T> Expression<T> visitRequired(ParseTree ctx) {
+
+		Expression<?> expression = visit(ctx);
+
+		if (expression == null) {
+			throw new UnsupportedOperationException("No result for expression: " + ctx.getText());
+		}
+
+		return (Expression<T>) expression;
+	}
+
+	private String getString(HqlParser.IdentifierContext context) {
+
+		HqlParser.NakedIdentifierContext ni = context.nakedIdentifier();
+
+		String text = context.getText();
+		if (ni != null) {
+			if (ni.QUOTED_IDENTIFIER() != null) {
+				text = unquoteIdentifier(ni.getText());
+			}
+		}
+		return text;
+	}
+
+	private static String getDecimals(TerminalNode input) {
+
+		String text = input.getText();
+		StringBuilder result = new StringBuilder(text.length());
+
+		for (int i = 0; i < text.length(); i++) {
+			char c = text.charAt(i);
+			if (Character.isDigit(c) || c == '-' || c == '+' || c == '.') {
+				result.append(c);
+			}
+		}
+
+		return result.toString();
+	}
+
+	private static String unquoteTemporal(ParseTree node) {
+		return unquoteTemporal(node.getText());
+	}
+
+	private static String unquoteTemporal(String temporal) {
+		if (temporal.startsWith("'") && temporal.endsWith("'")) {
+			temporal = temporal.substring(1, temporal.length() - 1);
+		}
+		return temporal;
+	}
+
+	private static String unquoteIdentifier(String text) {
+
+		int end = text.length() - 1;
+
+		Assert.isTrue(text.charAt(0) == '`' && text.charAt(end) == '`',
+				"Quoted identifier does not end with the same delimiter");
+
+		// Unquote a parsed quoted identifier and handle escape sequences
+		StringBuilder sb = new StringBuilder(text.length() - 2);
+		for (int i = 1; i < end; i++) {
+
+			char c = text.charAt(i);
+			if (c == '\\') {
+				if (i + 1 < end) {
+					char nextChar = text.charAt(++i);
+					switch (nextChar) {
+						case 'b':
+							c = '\b';
+							break;
+						case 't':
+							c = '\t';
+							break;
+						case 'n':
+							c = '\n';
+							break;
+						case 'f':
+							c = '\f';
+							break;
+						case 'r':
+							c = '\r';
+							break;
+						case '\\':
+							c = '\\';
+							break;
+						case '\'':
+							c = '\'';
+							break;
+						case '"':
+							c = '"';
+							break;
+						case '`':
+							c = '`';
+							break;
+						case 'u':
+							c = (char) Integer.parseInt(text.substring(i + 1, i + 5), 16);
+							i += 4;
+							break;
+						default:
+							sb.append('\\');
+							c = nextChar;
+							break;
+					}
+				}
+			}
+			sb.append(c);
+		}
+		return sb.toString();
+	}
+
+	private static String unquoteStringLiteral(String text) {
+
+		int end = text.length() - 1;
+		char delimiter = text.charAt(0);
+		Assert.isTrue(delimiter == text.charAt(end), "Quoted identifier does not end with the same delimiter");
+
+		// Unescape the parsed literal
+		StringBuilder sb = new StringBuilder(text.length() - 2);
+		for (int i = 1; i < end; i++) {
+			char c = text.charAt(i);
+			switch (c) {
+				case '\'':
+					if (delimiter == '\'') {
+						i++;
+					}
+					break;
+				case '"':
+					if (delimiter == '"') {
+						i++;
+					}
+					break;
+				default:
+					break;
+			}
+			sb.append(c);
+		}
+		return sb.toString();
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryIntrospector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryIntrospector.java
index 5ccc7b3556..d3ba055bb9 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryIntrospector.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryIntrospector.java
@@ -22,7 +22,8 @@
 import java.util.List;
 
 import org.springframework.data.jpa.repository.query.HqlParser.VariableContext;
-import org.springframework.lang.Nullable;
+
+import org.jspecify.annotations.Nullable;
 
 /**
  * {@link ParsedQueryIntrospector} for HQL queries.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryRenderer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryRenderer.java
index 36f8a2f62f..90aede7d7f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryRenderer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryRenderer.java
@@ -107,7 +107,7 @@ public QueryTokenStream visitQueryExpression(HqlParser.QueryExpressionContext ct
 	@Override
 	public QueryTokenStream visitWithClause(HqlParser.WithClauseContext ctx) {
 
-		QueryRendererBuilder builder = QueryRendererBuilder.from(TOKEN_WITH);
+		QueryRendererBuilder builder = QueryRendererBuilder.builder(TOKEN_WITH);
 		builder.append(QueryTokenStream.concatExpressions(ctx.cte(), this::visit, TOKEN_COMMA));
 
 		return builder;
@@ -336,8 +336,8 @@ public QueryTokenStream visitEntityWithJoins(HqlParser.EntityWithJoinsContext ct
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.appendExpression(visit(ctx.fromRoot()));
-		builder.appendInline(QueryTokenStream.concat(ctx.joinSpecifier(), this::visit, TOKEN_SPACE));
+		builder.appendInline(visit(ctx.fromRoot()));
+		builder.appendInline(QueryTokenStream.concat(ctx.joinSpecifier(), this::visit, EMPTY_TOKEN));
 
 		return builder;
 	}
@@ -396,6 +396,7 @@ public QueryTokenStream visitJoin(HqlParser.JoinContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
+		builder.append(TOKEN_SPACE);
 		builder.append(visit(ctx.joinType()));
 		builder.append(QueryTokens.expression(ctx.JOIN()));
 
@@ -668,7 +669,7 @@ public QueryTokenStream visitGroupedItem(HqlParser.GroupedItemContext ctx) {
 		if (ctx.identifier() != null) {
 			return visit(ctx.identifier());
 		} else if (ctx.INTEGER_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.INTEGER_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 		} else if (ctx.expression() != null) {
 			return visit(ctx.expression());
 		} else {
@@ -700,7 +701,7 @@ public QueryTokenStream visitSortExpression(HqlParser.SortExpressionContext ctx)
 		if (ctx.identifier() != null) {
 			return visit(ctx.identifier());
 		} else if (ctx.INTEGER_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.INTEGER_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 		} else if (ctx.expression() != null) {
 			return visit(ctx.expression());
 		} else {
@@ -712,9 +713,9 @@ public QueryTokenStream visitSortExpression(HqlParser.SortExpressionContext ctx)
 	public QueryTokenStream visitSortDirection(HqlParser.SortDirectionContext ctx) {
 
 		if (ctx.ASC() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.ASC()));
+			return QueryTokenStream.ofToken(ctx.ASC());
 		} else if (ctx.DESC() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.DESC()));
+			return QueryTokenStream.ofToken(ctx.DESC());
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -753,7 +754,7 @@ public QueryTokenStream visitOffsetClause(HqlParser.OffsetClauseContext ctx) {
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
 		builder.append(QueryTokens.expression(ctx.OFFSET()));
-		builder.append(visit(ctx.parameterOrIntegerLiteral()));
+		builder.appendExpression(visit(ctx.parameterOrIntegerLiteral()));
 
 		if (ctx.ROW() != null) {
 			builder.append(QueryTokens.expression(ctx.ROW()));
@@ -778,9 +779,9 @@ public QueryTokenStream visitFetchClause(HqlParser.FetchClauseContext ctx) {
 		}
 
 		if (ctx.parameterOrIntegerLiteral() != null) {
-			builder.append(visit(ctx.parameterOrIntegerLiteral()));
+			builder.appendExpression(visit(ctx.parameterOrIntegerLiteral()));
 		} else if (ctx.parameterOrNumberLiteral() != null) {
-			builder.append(visit(ctx.parameterOrNumberLiteral()));
+			builder.appendExpression(visit(ctx.parameterOrNumberLiteral()));
 		}
 
 		if (ctx.ROW() != null) {
@@ -1029,13 +1030,13 @@ public QueryTokenStream visitSetOperator(HqlParser.SetOperatorContext ctx) {
 	public QueryTokenStream visitLiteral(HqlParser.LiteralContext ctx) {
 
 		if (ctx.NULL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.NULL()));
+			return QueryTokenStream.ofToken(ctx.NULL());
 		} else if (ctx.booleanLiteral() != null) {
 			return visit(ctx.booleanLiteral());
 		} else if (ctx.JAVA_STRING_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.JAVA_STRING_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.JAVA_STRING_LITERAL());
 		} else if (ctx.STRING_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.STRING_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.STRING_LITERAL());
 		} else if (ctx.numericLiteral() != null) {
 			return visit(ctx.numericLiteral());
 		} else if (ctx.temporalLiteral() != null) {
@@ -1055,9 +1056,9 @@ public QueryTokenStream visitLiteral(HqlParser.LiteralContext ctx) {
 	public QueryTokenStream visitBooleanLiteral(HqlParser.BooleanLiteralContext ctx) {
 
 		if (ctx.TRUE() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.TRUE()));
+			return QueryTokenStream.ofToken(ctx.TRUE());
 		} else if (ctx.FALSE() != null) {
-			return QueryRendererBuilder.from(QueryTokens.expression(ctx.FALSE()));
+			return QueryTokenStream.ofToken(ctx.FALSE());
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -1067,19 +1068,19 @@ public QueryTokenStream visitBooleanLiteral(HqlParser.BooleanLiteralContext ctx)
 	public QueryTokenStream visitNumericLiteral(HqlParser.NumericLiteralContext ctx) {
 
 		if (ctx.INTEGER_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 		} else if (ctx.LONG_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.LONG_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.LONG_LITERAL());
 		} else if (ctx.BIG_INTEGER_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.BIG_INTEGER_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.BIG_INTEGER_LITERAL());
 		} else if (ctx.FLOAT_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.FLOAT_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.FLOAT_LITERAL());
 		} else if (ctx.DOUBLE_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.DOUBLE_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.DOUBLE_LITERAL());
 		} else if (ctx.BIG_DECIMAL_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.BIG_DECIMAL_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.BIG_DECIMAL_LITERAL());
 		} else if (ctx.HEX_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.HEX_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.HEX_LITERAL());
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -1309,7 +1310,7 @@ public QueryTokenStream visitJdbcTimeLiteral(HqlParser.JdbcTimeLiteralContext ct
 
 	@Override
 	public QueryTokenStream visitGenericTemporalLiteralText(HqlParser.GenericTemporalLiteralTextContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.STRING_LITERAL());
 	}
 
 	@Override
@@ -1338,12 +1339,12 @@ public QueryTokenStream visitGeneralizedLiteral(HqlParser.GeneralizedLiteralCont
 
 	@Override
 	public QueryTokenStream visitGeneralizedLiteralType(HqlParser.GeneralizedLiteralTypeContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.STRING_LITERAL());
 	}
 
 	@Override
 	public QueryTokenStream visitGeneralizedLiteralText(HqlParser.GeneralizedLiteralTextContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.STRING_LITERAL());
 	}
 
 	@Override
@@ -1414,62 +1415,62 @@ public QueryTokenStream visitOffsetWithMinutes(HqlParser.OffsetWithMinutesContex
 
 	@Override
 	public QueryTokenStream visitYear(HqlParser.YearContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 	}
 
 	@Override
 	public QueryTokenStream visitMonth(HqlParser.MonthContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 	}
 
 	@Override
 	public QueryTokenStream visitDay(HqlParser.DayContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 	}
 
 	@Override
 	public QueryTokenStream visitHour(HqlParser.HourContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 	}
 
 	@Override
 	public QueryTokenStream visitMinute(HqlParser.MinuteContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 	}
 
 	@Override
 	public QueryTokenStream visitSecond(HqlParser.SecondContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 	}
 
 	@Override
 	public QueryTokenStream visitZoneId(HqlParser.ZoneIdContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.STRING_LITERAL());
 	}
 
 	@Override
 	public QueryTokenStream visitDatetimeField(HqlParser.DatetimeFieldContext ctx) {
 
 		if (ctx.YEAR() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.YEAR()));
+			return QueryTokenStream.ofToken(ctx.YEAR());
 		} else if (ctx.MONTH() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.MONTH()));
+			return QueryTokenStream.ofToken(ctx.MONTH());
 		} else if (ctx.DAY() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.DAY()));
+			return QueryTokenStream.ofToken(ctx.DAY());
 		} else if (ctx.WEEK() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.WEEK()));
+			return QueryTokenStream.ofToken(ctx.WEEK());
 		} else if (ctx.QUARTER() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.QUARTER()));
+			return QueryTokenStream.ofToken(ctx.QUARTER());
 		} else if (ctx.HOUR() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.HOUR()));
+			return QueryTokenStream.ofToken(ctx.HOUR());
 		} else if (ctx.MINUTE() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.MINUTE()));
+			return QueryTokenStream.ofToken(ctx.MINUTE());
 		} else if (ctx.SECOND() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.SECOND()));
+			return QueryTokenStream.ofToken(ctx.SECOND());
 		} else if (ctx.NANOSECOND() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.NANOSECOND()));
+			return QueryTokenStream.ofToken(ctx.NANOSECOND());
 		} else if (ctx.EPOCH() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.EPOCH()));
+			return QueryTokenStream.ofToken(ctx.EPOCH());
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -1547,7 +1548,7 @@ public QueryTokenStream visitTimeZoneField(HqlParser.TimeZoneFieldContext ctx) {
 
 	@Override
 	public QueryTokenStream visitDateOrTimeField(HqlParser.DateOrTimeFieldContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.expression(ctx.DATE() != null ? ctx.DATE() : ctx.TIME()));
+		return QueryTokenStream.ofToken(ctx.DATE() != null ? ctx.DATE() : ctx.TIME());
 	}
 
 	@Override
@@ -1560,11 +1561,7 @@ public QueryTokenStream visitBinaryLiteral(HqlParser.BinaryLiteralContext ctx) {
 		} else if (ctx.HEX_LITERAL() != null) {
 
 			builder.append(TOKEN_OPEN_BRACE);
-
-			builder.append(QueryTokenStream.concat(ctx.HEX_LITERAL(), it -> {
-				return QueryRendererBuilder.from(QueryTokens.token(it));
-			}, TOKEN_COMMA));
-
+			builder.append(QueryTokenStream.concat(ctx.HEX_LITERAL(), QueryTokenStream::ofToken, TOKEN_COMMA));
 			builder.append(TOKEN_CLOSE_BRACE);
 		}
 
@@ -1741,7 +1738,7 @@ public QueryTokenStream visitToDurationExpression(HqlParser.ToDurationExpression
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(visit(ctx.expression()));
+		builder.appendExpression(visit(ctx.expression()));
 		builder.appendExpression(visit(ctx.datetimeField()));
 
 		return builder;
@@ -2163,12 +2160,12 @@ public QueryTokenStream visitPadFunction(HqlParser.PadFunctionContext ctx) {
 
 	@Override
 	public QueryTokenStream visitPadSpecification(HqlParser.PadSpecificationContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.LEADING() != null ? ctx.LEADING() : ctx.TRAILING()));
+		return QueryTokenStream.ofToken(ctx.LEADING() != null ? ctx.LEADING() : ctx.TRAILING());
 	}
 
 	@Override
 	public QueryTokenStream visitPadCharacter(HqlParser.PadCharacterContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.STRING_LITERAL());
 	}
 
 	@Override
@@ -2180,12 +2177,16 @@ public QueryTokenStream visitPadLength(HqlParser.PadLengthContext ctx) {
 	public QueryTokenStream visitPositionFunction(HqlParser.PositionFunctionContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
+		QueryRendererBuilder nested = QueryRenderer.builder();
 
 		builder.append(QueryTokens.token(ctx.POSITION()));
 		builder.append(TOKEN_OPEN_PAREN);
-		builder.append(visit(ctx.positionFunctionPatternArgument()));
-		builder.append(QueryTokens.expression(ctx.IN()));
-		builder.appendInline(visit(ctx.positionFunctionStringArgument()));
+
+		nested.appendExpression(visit(ctx.positionFunctionPatternArgument()));
+		nested.append(QueryTokens.expression(ctx.IN()));
+		nested.append(visit(ctx.positionFunctionStringArgument()));
+
+		builder.appendInline(nested);
 		builder.append(TOKEN_CLOSE_PAREN);
 
 		return builder;
@@ -2208,7 +2209,7 @@ public QueryTokenStream visitOverlayFunction(HqlParser.OverlayFunctionContext ct
 
 		builder.append(QueryTokens.token(ctx.OVERLAY()));
 		builder.append(TOKEN_OPEN_PAREN);
-		builder.append(visit(ctx.overlayFunctionStringArgument()));
+		builder.appendExpression(visit(ctx.overlayFunctionStringArgument()));
 		builder.append(QueryTokens.expression(ctx.PLACING()));
 		builder.append(visit(ctx.overlayFunctionReplacementArgument()));
 		builder.append(QueryTokens.expression(ctx.FROM()));
@@ -2442,7 +2443,7 @@ public QueryTokenStream visitRollup(HqlParser.RollupContext ctx) {
 
 	@Override
 	public QueryTokenStream visitFormat(HqlParser.FormatContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.STRING_LITERAL());
 	}
 
 	@Override
@@ -2504,7 +2505,7 @@ public QueryTokenStream visitJpaNonstandardFunctionName(HqlParser.JpaNonstandard
 			return visit(ctx.identifier());
 		}
 
-		return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL()));
+		return QueryTokenStream.ofToken(ctx.STRING_LITERAL());
 	}
 
 	@Override
@@ -3003,10 +3004,7 @@ public QueryTokenStream visitSimpleCaseExpression(HqlParser.SimpleCaseExpression
 
 		builder.append(QueryTokens.expression(ctx.CASE()));
 		builder.append(visit(ctx.expressionOrPredicate(0)));
-
-		ctx.caseWhenExpressionClause().forEach(caseWhenExpressionClauseContext -> {
-			builder.append(visit(caseWhenExpressionClauseContext));
-		});
+		builder.appendExpression(QueryTokenStream.concat(ctx.caseWhenExpressionClause(), this::visit, TOKEN_SPACE));
 
 		if (ctx.ELSE() != null) {
 
@@ -3249,7 +3247,7 @@ public QueryTokenStream visitCastTarget(HqlParser.CastTargetContext ctx) {
 
 	@Override
 	public QueryTokenStream visitCastTargetType(HqlParser.CastTargetTypeContext ctx) {
-		return QueryRendererBuilder.from(QueryTokens.expression(ctx.fullTargetName));
+		return QueryTokenStream.from(QueryTokens.token(ctx.fullTargetName));
 	}
 
 	@Override
@@ -3266,7 +3264,7 @@ public QueryTokenStream visitExtractFunction(HqlParser.ExtractFunctionContext ct
 
 			nested.appendExpression(visit(ctx.extractField()));
 			nested.append(QueryTokens.expression(ctx.FROM()));
-			nested.append(visit(ctx.expression()));
+			nested.appendExpression(visit(ctx.expression()));
 
 			builder.appendInline(nested);
 			builder.append(TOKEN_CLOSE_PAREN);
@@ -3356,7 +3354,7 @@ public QueryTokenStream visitTrimSpecification(HqlParser.TrimSpecificationContex
 	public QueryTokenStream visitTrimCharacter(HqlParser.TrimCharacterContext ctx) {
 
 		if (ctx.STRING_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.STRING_LITERAL());
 		}
 
 		return visit(ctx.parameter());
@@ -3387,7 +3385,7 @@ public QueryTokenStream visitEveryFunction(HqlParser.EveryFunctionContext ctx) {
 			builder.append(TOKEN_CLOSE_PAREN);
 		} else {
 
-			builder.appendExpression(visit(ctx.collectionQuantifier()));
+			builder.append(visit(ctx.collectionQuantifier()));
 
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.appendInline(visit(ctx.simplePath()));
@@ -3422,7 +3420,7 @@ public QueryTokenStream visitAnyFunction(HqlParser.AnyFunctionContext ctx) {
 			builder.append(TOKEN_CLOSE_PAREN);
 		} else {
 
-			builder.appendExpression(visit(ctx.collectionQuantifier()));
+			builder.append(visit(ctx.collectionQuantifier()));
 
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.appendInline(visit(ctx.simplePath()));
@@ -3506,11 +3504,11 @@ public QueryTokenStream visitToOneFkReference(HqlParser.ToOneFkReferenceContext
 	public QueryTokenStream visitElementValueQuantifier(HqlParser.ElementValueQuantifierContext ctx) {
 
 		if (ctx.ELEMENT() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.ELEMENT()));
+			return QueryTokenStream.ofToken(ctx.ELEMENT());
 		}
 
 		if (ctx.VALUE() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.VALUE()));
+			return QueryTokenStream.ofToken(ctx.VALUE());
 		}
 
 		return QueryTokenStream.empty();
@@ -3520,11 +3518,11 @@ public QueryTokenStream visitElementValueQuantifier(HqlParser.ElementValueQuanti
 	public QueryTokenStream visitIndexKeyQuantifier(HqlParser.IndexKeyQuantifierContext ctx) {
 
 		if (ctx.INDEX() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.INDEX()));
+			return QueryTokenStream.ofToken(ctx.INDEX());
 		}
 
 		if (ctx.KEY() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.KEY()));
+			return QueryTokenStream.ofToken(ctx.KEY());
 		}
 
 		return QueryTokenStream.empty();
@@ -3811,9 +3809,9 @@ public QueryTokenStream visitInList(HqlParser.InListContext ctx) {
 		if (ctx.simplePath() != null) {
 
 			if (ctx.ELEMENTS() != null) {
-				builder.append(QueryTokens.expression(ctx.ELEMENTS()));
+				builder.append(QueryTokens.token(ctx.ELEMENTS()));
 			} else if (ctx.INDICES() != null) {
-				builder.append(QueryTokens.expression(ctx.INDICES()));
+				builder.append(QueryTokens.token(ctx.INDICES()));
 			}
 
 			builder.append(TOKEN_OPEN_PAREN);
@@ -3846,9 +3844,9 @@ public QueryTokenStream visitExistsExpression(HqlParser.ExistsExpressionContext
 			builder.append(QueryTokens.expression(ctx.EXISTS()));
 
 			if (ctx.ELEMENTS() != null) {
-				builder.append(QueryTokens.expression(ctx.ELEMENTS()));
+				builder.append(QueryTokens.token(ctx.ELEMENTS()));
 			} else if (ctx.INDICES() != null) {
-				builder.append(QueryTokens.expression(ctx.INDICES()));
+				builder.append(QueryTokens.token(ctx.INDICES()));
 			}
 
 			builder.append(TOKEN_OPEN_PAREN);
@@ -3868,11 +3866,10 @@ public QueryTokenStream visitExistsExpression(HqlParser.ExistsExpressionContext
 	public QueryTokenStream visitInstantiationTarget(HqlParser.InstantiationTargetContext ctx) {
 
 		if (ctx.LIST() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.LIST()));
+			return QueryTokenStream.ofToken(ctx.LIST());
 		} else if (ctx.MAP() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.MAP()));
+			return QueryTokenStream.ofToken(ctx.MAP());
 		} else if (ctx.simplePath() != null) {
-
 			return visit(ctx.simplePath());
 		} else {
 			return QueryTokenStream.empty();
@@ -3908,7 +3905,7 @@ public QueryTokenStream visitParameterOrIntegerLiteral(HqlParser.ParameterOrInte
 		if (ctx.parameter() != null) {
 			return visit(ctx.parameter());
 		} else if (ctx.INTEGER_LITERAL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL()));
+			return QueryTokenStream.ofToken(ctx.INTEGER_LITERAL());
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -3974,15 +3971,15 @@ public QueryTokenStream visitIdentifier(HqlParser.IdentifierContext ctx) {
 		if (ctx.nakedIdentifier() != null) {
 			return visit(ctx.nakedIdentifier());
 		} else if (ctx.FULL() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.FULL()));
+			return QueryTokenStream.ofToken(ctx.FULL());
 		} else if (ctx.LEFT() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.LEFT()));
+			return QueryTokenStream.ofToken(ctx.LEFT());
 		} else if (ctx.INNER() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.INNER()));
+			return QueryTokenStream.ofToken(ctx.INNER());
 		} else if (ctx.OUTER() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.OUTER()));
+			return QueryTokenStream.ofToken(ctx.OUTER());
 		} else if (ctx.RIGHT() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.RIGHT()));
+			return QueryTokenStream.ofToken(ctx.RIGHT());
 		}
 
 		return QueryTokenStream.empty();
@@ -3992,11 +3989,11 @@ public QueryTokenStream visitIdentifier(HqlParser.IdentifierContext ctx) {
 	public QueryTokenStream visitNakedIdentifier(HqlParser.NakedIdentifierContext ctx) {
 
 		if (ctx.IDENTIFIER() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.IDENTIFIER()));
+			return QueryTokenStream.ofToken(ctx.IDENTIFIER());
 		} else if (ctx.QUOTED_IDENTIFIER() != null) {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.QUOTED_IDENTIFIER()));
+			return QueryTokenStream.ofToken(ctx.QUOTED_IDENTIFIER());
 		} else {
-			return QueryRendererBuilder.from(QueryTokens.token(ctx.f));
+			return QueryTokenStream.ofToken(ctx.f);
 		}
 	}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java
index b5784b31dc..9a3220dd1f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java
@@ -20,9 +20,10 @@
 import java.util.List;
 
 import org.springframework.data.domain.Sort;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
 import org.springframework.data.repository.query.ReturnedType;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.ObjectUtils;
 
@@ -104,7 +105,7 @@ public QueryTokenStream visitJoinPath(HqlParser.JoinPathContext ctx) {
 		QueryTokenStream tokens = super.visitJoinPath(ctx);
 
 		if (ctx.variable() != null && !isSubquery(ctx)) {
-			transformerSupport.registerAlias(tokens.getLast());
+			transformerSupport.registerAlias(tokens.getRequiredLast());
 		}
 
 		return tokens;
@@ -116,7 +117,7 @@ public QueryTokenStream visitJoinSubquery(HqlParser.JoinSubqueryContext ctx) {
 		QueryTokenStream tokens = super.visitJoinSubquery(ctx);
 
 		if (ctx.variable() != null && !tokens.isEmpty() && !isSubquery(ctx)) {
-			transformerSupport.registerAlias(tokens.getLast());
+			transformerSupport.registerAlias(tokens.getRequiredLast());
 		}
 
 		return tokens;
@@ -128,7 +129,7 @@ public QueryTokenStream visitVariable(HqlParser.VariableContext ctx) {
 		QueryTokenStream tokens = super.visitVariable(ctx);
 
 		if (ctx.identifier() != null && !tokens.isEmpty() && !isSubquery(ctx)) {
-			transformerSupport.registerAlias(tokens.getLast());
+			transformerSupport.registerAlias(tokens.getRequiredLast());
 		}
 
 		return tokens;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java
index 1f4b3b8b1b..a5d907354d 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java
@@ -48,11 +48,16 @@
 import java.util.List;
 import java.util.Set;
 import java.util.StringJoiner;
+import java.util.function.Predicate;
+import java.util.function.Supplier;
+
+import org.jspecify.annotations.Nullable;
 
 import org.springframework.data.domain.Sort;
-import org.springframework.lang.Nullable;
+import org.springframework.data.util.Predicates;
 import org.springframework.util.Assert;
 import org.springframework.util.CollectionUtils;
+import org.springframework.util.ObjectUtils;
 import org.springframework.util.SerializationUtils;
 import org.springframework.util.StringUtils;
 
@@ -64,11 +69,12 @@
  * @author Geoffrey Deremetz
  * @author Yanming Zhou
  * @author Christoph Strobl
+ * @author Diego Pedregal
  * @since 2.7.0
  */
 public class JSqlParserQueryEnhancer implements QueryEnhancer {
 
-	private final DeclaredQuery query;
+	private final QueryProvider query;
 	private final Statement statement;
 	private final ParsedType parsedType;
 	private final boolean hasConstructorExpression;
@@ -76,12 +82,12 @@ public class JSqlParserQueryEnhancer implements QueryEnhancer {
 	private final String projection;
 	private final Set<String> joinAliases;
 	private final Set<String> selectAliases;
-	private final byte[] serialized;
+	private final byte @Nullable [] serialized;
 
 	/**
 	 * @param query the query we want to enhance. Must not be {@literal null}.
 	 */
-	public JSqlParserQueryEnhancer(DeclaredQuery query) {
+	public JSqlParserQueryEnhancer(QueryProvider query) {
 
 		this.query = query;
 		this.statement = parseStatement(query.getQueryString(), Statement.class);
@@ -92,13 +98,16 @@ public JSqlParserQueryEnhancer(DeclaredQuery query) {
 		this.projection = detectProjection(this.statement);
 		this.selectAliases = Collections.unmodifiableSet(getSelectionAliases(this.statement));
 		this.joinAliases = Collections.unmodifiableSet(getJoinAliases(this.statement));
+		byte[] tmp = SerializationUtils.serialize(this.statement);
+		// this.serialized = tmp != null ? tmp : new byte[0];
 		this.serialized = SerializationUtils.serialize(this.statement);
 	}
 
 	/**
 	 * Parses a query string with JSqlParser.
 	 *
-	 * @param query the query to parse
+	 * @param sql the query to parse
+	 * @param classOfT the query to parse
 	 * @return the parsed query
 	 */
 	static <T extends Statement> T parseStatement(String sql, Class<T> classOfT) {
@@ -130,8 +139,7 @@ static <T extends Statement> T parseStatement(String sql, Class<T> classOfT) {
 	 *
 	 * @return Might return {@literal null}.
 	 */
-	@Nullable
-	private static String detectAlias(ParsedType parsedType, Statement statement) {
+	private static @Nullable String detectAlias(ParsedType parsedType, Statement statement) {
 
 		if (ParsedType.MERGE.equals(parsedType)) {
 
@@ -144,24 +152,8 @@ private static String detectAlias(ParsedType parsedType, Statement statement) {
 
 		if (ParsedType.SELECT.equals(parsedType)) {
 
-			Select selectStatement = (Select) statement;
-
-			/*
-			 * For all the other types ({@link ValuesStatement} and {@link SetOperationList}) it does not make sense to provide
-			 * alias since:
-			 * ValuesStatement has no alias
-			 * SetOperation can have multiple alias for each operation item
-			 */
-			if (!(selectStatement instanceof PlainSelect selectBody)) {
-				return null;
-			}
-
-			if (selectBody.getFromItem() == null) {
-				return null;
-			}
-
-			Alias alias = selectBody.getFromItem().getAlias();
-			return alias == null ? null : alias.getName();
+			return doWithPlainSelect(statement, it -> it.getFromItem() == null || it.getFromItem().getAlias() == null,
+					it -> it.getFromItem().getAlias().getName(), () -> null);
 		}
 
 		return null;
@@ -174,20 +166,24 @@ private static String detectAlias(ParsedType parsedType, Statement statement) {
 	 */
 	private static Set<String> getSelectionAliases(Statement statement) {
 
-		if (!(statement instanceof PlainSelect select) || CollectionUtils.isEmpty(select.getSelectItems())) {
-			return Collections.emptySet();
+		if (statement instanceof SetOperationList sel) {
+			statement = sel.getSelect(0);
 		}
 
-		Set<String> set = new HashSet<>(select.getSelectItems().size());
+		return doWithPlainSelect(statement, it -> CollectionUtils.isEmpty(it.getSelectItems()), it -> {
+
+			Set<String> set = new HashSet<>(it.getSelectItems().size(), 1.0f);
 
-		for (SelectItem<?> selectItem : select.getSelectItems()) {
-			Alias alias = selectItem.getAlias();
-			if (alias != null) {
-				set.add(alias.getName());
+			for (SelectItem<?> selectItem : it.getSelectItems()) {
+				Alias alias = selectItem.getAlias();
+				if (alias != null) {
+					set.add(alias.getName());
+				}
 			}
-		}
 
-		return set;
+			return set;
+
+		}, Collections::emptySet);
 	}
 
 	/**
@@ -197,21 +193,74 @@ private static Set<String> getSelectionAliases(Statement statement) {
 	 */
 	private static Set<String> getJoinAliases(Statement statement) {
 
-		if (!(statement instanceof PlainSelect selectBody) || CollectionUtils.isEmpty(selectBody.getJoins())) {
-			return Collections.emptySet();
+		if (statement instanceof SetOperationList sel) {
+			statement = sel.getSelect(0);
 		}
 
-		Set<String> set = new HashSet<>(selectBody.getJoins().size());
+		return doWithPlainSelect(statement, it -> CollectionUtils.isEmpty(it.getJoins()), it -> {
+
+			Set<String> set = new HashSet<>(it.getJoins().size(), 1.0f);
 
-		for (Join join : selectBody.getJoins()) {
-			Alias alias = join.getRightItem().getAlias();
-			if (alias != null) {
-				set.add(alias.getName());
+			for (Join join : it.getJoins()) {
+				Alias alias = join.getRightItem().getAlias();
+				if (alias != null) {
+					set.add(alias.getName());
+				}
 			}
+			return set;
+
+		}, Collections::emptySet);
+	}
+
+	/**
+	 * Apply a {@link java.util.function.Function mapping function} to the {@link PlainSelect} of the given
+	 * {@link Statement} is or contains a {@link PlainSelect}.
+	 *
+	 * @param statement
+	 * @param mapper
+	 * @param fallback
+	 * @return
+	 * @param <T>
+	 */
+	private static <T> T doWithPlainSelect(Statement statement, java.util.function.Function<PlainSelect, T> mapper,
+			Supplier<T> fallback) {
+
+		Predicate<PlainSelect> neverSkip = Predicates.isFalse();
+		return doWithPlainSelect(statement, neverSkip, mapper, fallback);
+	}
+
+	/**
+	 * Apply a {@link java.util.function.Function mapping function} to the {@link PlainSelect} of the given
+	 * {@link Statement} is or contains a {@link PlainSelect}.
+	 * <p>
+	 * The operation is only applied if {@link Predicate skipIf} returns {@literal false} for the given statement
+	 * returning the fallback value from {@code fallback}.
+	 *
+	 * @param statement
+	 * @param skipIf
+	 * @param mapper
+	 * @param fallback
+	 * @return
+	 * @param <T>
+	 */
+	private static <T> T doWithPlainSelect(Statement statement, Predicate<PlainSelect> skipIf,
+			java.util.function.Function<PlainSelect, T> mapper, Supplier<T> fallback) {
+
+		if (!(statement instanceof Select select)) {
+			return fallback.get();
 		}
 
-		return set;
+		try {
+			if (skipIf.test(select.getPlainSelect())) {
+				return fallback.get();
+			}
+		}
+		// e.g. SetOperationList is a subclass of Select but it is not a PlainSelect
+		catch (ClassCastException e) {
+			return fallback.get();
+		}
 
+		return mapper.apply(select.getPlainSelect());
 	}
 
 	private static String detectProjection(Statement statement) {
@@ -230,18 +279,17 @@ private static String detectProjection(Statement statement) {
 
 			// using the first one since for setoperations the projection has to be the same
 			selectBody = setOperationList.getSelects().get(0);
-
-			if (!(selectBody instanceof PlainSelect)) {
-				return "";
-			}
 		}
 
-		StringJoiner joiner = new StringJoiner(", ");
-		for (SelectItem<?> selectItem : ((PlainSelect) selectBody).getSelectItems()) {
-			joiner.add(selectItem.toString());
-		}
-		return joiner.toString().trim();
+		return doWithPlainSelect(selectBody, it -> CollectionUtils.isEmpty(it.getSelectItems()), it -> {
 
+			StringJoiner joiner = new StringJoiner(", ");
+			for (SelectItem<?> selectItem : it.getSelectItems()) {
+				joiner.add(selectItem.toString());
+			}
+			return joiner.toString().trim();
+
+		}, () -> "");
 	}
 
 	/**
@@ -272,7 +320,7 @@ public boolean hasConstructorExpression() {
 	}
 
 	@Override
-	public String detectAlias() {
+	public @Nullable String detectAlias() {
 		return this.primaryAlias;
 	}
 
@@ -281,35 +329,20 @@ public String getProjection() {
 		return this.projection;
 	}
 
-	@Override
-	public Set<String> getJoinAliases() {
-		return joinAliases;
-	}
-
 	public Set<String> getSelectionAliases() {
 		return selectAliases;
 	}
 
 	@Override
-	public DeclaredQuery getQuery() {
+	public QueryProvider getQuery() {
 		return this.query;
 	}
 
-	@Override
-	public String applySorting(Sort sort) {
-		return doApplySorting(sort, detectAlias());
-	}
-
 	@Override
 	public String rewrite(QueryRewriteInformation rewriteInformation) {
 		return doApplySorting(rewriteInformation.getSort(), primaryAlias);
 	}
 
-	@Override
-	public String applySorting(Sort sort, @Nullable String alias) {
-		return doApplySorting(sort, alias);
-	}
-
 	private String doApplySorting(Sort sort, @Nullable String alias) {
 		String queryString = query.getQueryString();
 		Assert.hasText(queryString, "Query must not be null or empty");
@@ -318,29 +351,31 @@ private String doApplySorting(Sort sort, @Nullable String alias) {
 			return queryString;
 		}
 
-		return applySorting((Select) deserialize(this.serialized), sort, alias);
+		return applySorting(deserializeRequired(this.serialized, Select.class), sort, alias);
 	}
 
-	private String applySorting(Select selectStatement, Sort sort, @Nullable String alias) {
+	private String applySorting(@Nullable Select selectStatement, Sort sort, @Nullable String alias) {
 
 		if (selectStatement instanceof SetOperationList setOperationList) {
 			return applySortingToSetOperationList(setOperationList, sort);
 		}
 
-		if (!(selectStatement instanceof PlainSelect selectBody)) {
-			return selectStatement.toString();
-		}
+		doWithPlainSelect(selectStatement, it -> {
 
-		List<OrderByElement> orderByElements = new ArrayList<>(16);
-		for (Sort.Order order : sort) {
-			orderByElements.add(getOrderClause(joinAliases, selectAliases, alias, order));
-		}
+			List<OrderByElement> orderByElements = new ArrayList<>(16);
+			for (Sort.Order order : sort) {
+				orderByElements.add(getOrderClause(joinAliases, selectAliases, alias, order));
+			}
 
-		if (CollectionUtils.isEmpty(selectBody.getOrderByElements())) {
-			selectBody.setOrderByElements(orderByElements);
-		} else {
-			selectBody.getOrderByElements().addAll(orderByElements);
-		}
+			if (CollectionUtils.isEmpty(it.getOrderByElements())) {
+				it.setOrderByElements(orderByElements);
+			} else {
+				it.getOrderByElements().addAll(orderByElements);
+			}
+
+			return null;
+
+		}, () -> "");
 
 		return selectStatement.toString();
 	}
@@ -355,18 +390,13 @@ public String createCountQueryFor(@Nullable String countProjection) {
 		Assert.hasText(this.query.getQueryString(), "OriginalQuery must not be null or empty");
 
 		Statement statement = (Statement) deserialize(this.serialized);
-		/*
-		  We only support count queries for {@link PlainSelect}.
-		 */
-		if (!(statement instanceof PlainSelect selectBody)) {
-			return this.query.getQueryString();
-		}
 
-		return createCountQueryFor(this.query, selectBody, countProjection, primaryAlias);
+		return doWithPlainSelect(statement, it -> createCountQueryFor(it, countProjection, primaryAlias),
+				this.query::getQueryString);
 	}
 
-	private static String createCountQueryFor(DeclaredQuery query, PlainSelect selectBody,
-			@Nullable String countProjection, @Nullable String primaryAlias) {
+	private static String createCountQueryFor(PlainSelect selectBody, @Nullable String countProjection,
+			@Nullable String primaryAlias) {
 
 		// remove order by
 		selectBody.setOrderByElements(null);
@@ -510,7 +540,7 @@ private static boolean onlyASingleColumnProjection(List<SelectItem<?>> projectio
 	 * </ul>
 	 */
 	enum ParsedType {
-		DELETE, UPDATE, SELECT, INSERT, MERGE, OTHER;
+		DELETE, UPDATE, SELECT, INSERT, MERGE, OTHER
 	}
 
 	/**
@@ -519,7 +549,10 @@ enum ParsedType {
 	 * @param bytes a serialized object
 	 * @return the result of deserializing the bytes
 	 */
-	private static Object deserialize(byte[] bytes) {
+	private static @Nullable Object deserialize(byte @Nullable [] bytes) {
+		if (ObjectUtils.isEmpty(bytes)) {
+			return null;
+		}
 		try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
 			return ois.readObject();
 		} catch (IOException ex) {
@@ -529,4 +562,12 @@ private static Object deserialize(byte[] bytes) {
 		}
 	}
 
+	private static <T> T deserializeRequired(byte @Nullable [] bytes, Class<T> type) {
+		Object deserialize = deserialize(bytes);
+		if (deserialize != null) {
+			return type.cast(deserialize);
+		}
+		throw new IllegalStateException("Failed to deserialize object type");
+	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Jpa21Utils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Jpa21Utils.java
index 4530aac26b..ff1ce50b54 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Jpa21Utils.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Jpa21Utils.java
@@ -26,8 +26,9 @@
 import java.util.List;
 
 import org.springframework.data.jpa.repository.support.MutableQueryHints;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.support.QueryHints;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.ObjectUtils;
 import org.springframework.util.StringUtils;
@@ -188,8 +189,7 @@ private static boolean exists(String attributeNodeName, List<AttributeNode<?>> n
 	 * @param parent
 	 * @return {@literal null} if not found.
 	 */
-	@Nullable
-	private static AttributeNode<?> findAttributeNode(String attributeNodeName, EntityGraph<?> entityGraph,
+	private static @Nullable AttributeNode<?> findAttributeNode(String attributeNodeName, EntityGraph<?> entityGraph,
 			@Nullable Subgraph<?> parent) {
 		return findAttributeNode(attributeNodeName,
 				parent != null ? parent.getAttributeNodes() : entityGraph.getAttributeNodes());
@@ -203,8 +203,7 @@ private static AttributeNode<?> findAttributeNode(String attributeNodeName, Enti
 	 * @param nodes
 	 * @return {@literal null} if not found.
 	 */
-	@Nullable
-	private static AttributeNode<?> findAttributeNode(String attributeNodeName, List<AttributeNode<?>> nodes) {
+	private static @Nullable AttributeNode<?> findAttributeNode(String attributeNodeName, List<AttributeNode<?>> nodes) {
 
 		for (AttributeNode<?> node : nodes) {
 			if (ObjectUtils.nullSafeEquals(node.getAttributeName(), attributeNodeName)) {
@@ -223,8 +222,7 @@ private static AttributeNode<?> findAttributeNode(String attributeNodeName, List
 	 * @param node
 	 * @return
 	 */
-	@Nullable
-	private static Subgraph<?> getSubgraph(AttributeNode<?> node) {
+	private static @Nullable Subgraph<?> getSubgraph(AttributeNode<?> node) {
 		return node.getSubgraphs().isEmpty() ? null : node.getSubgraphs().values().iterator().next();
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java
index d9b69f362f..c0f5c49d73 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java
@@ -15,16 +15,13 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import jakarta.persistence.criteria.CriteriaBuilder;
-import jakarta.persistence.criteria.CriteriaQuery;
-import jakarta.persistence.criteria.Expression;
-import jakarta.persistence.criteria.Predicate;
-import jakarta.persistence.criteria.Root;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.metamodel.Metamodel;
 
 import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.repository.query.parser.PartTree;
-import org.springframework.lang.Nullable;
 
 /**
  * Special {@link JpaQueryCreator} that creates a count projecting query.
@@ -37,39 +34,51 @@
 public class JpaCountQueryCreator extends JpaQueryCreator {
 
 	private final boolean distinct;
+	private final ReturnedType returnedType;
 
 	/**
-	 * Creates a new {@link JpaCountQueryCreator}.
+	 * Creates a new {@link JpaCountQueryCreator}
 	 *
 	 * @param tree
-	 * @param type
-	 * @param builder
+	 * @param returnedType
 	 * @param provider
+	 * @param templates
+	 * @param em
 	 */
-	public JpaCountQueryCreator(PartTree tree, ReturnedType type, CriteriaBuilder builder,
-			ParameterMetadataProvider provider) {
+	public JpaCountQueryCreator(PartTree tree, ReturnedType returnedType, ParameterMetadataProvider provider,
+			JpqlQueryTemplates templates, EntityManager em) {
 
-		super(tree, type, builder, provider);
+		super(tree, returnedType, provider, templates, em);
 
 		this.distinct = tree.isDistinct();
+		this.returnedType = returnedType;
 	}
 
-	@Override
-	protected CriteriaQuery<? extends Object> createCriteriaQuery(CriteriaBuilder builder, ReturnedType type) {
-		return builder.createQuery(Long.class);
-	}
+	/**
+	 * Creates a new {@link JpaCountQueryCreator}
+	 *
+	 * @param tree
+	 * @param returnedType
+	 * @param provider
+	 * @param templates
+	 * @param metamodel
+	 */
+	public JpaCountQueryCreator(PartTree tree, ReturnedType returnedType, ParameterMetadataProvider provider,
+			JpqlQueryTemplates templates, Metamodel metamodel) {
 
-	@Override
-	@SuppressWarnings("unchecked")
-	protected CriteriaQuery<? extends Object> complete(@Nullable Predicate predicate, Sort sort,
-			CriteriaQuery<? extends Object> query, CriteriaBuilder builder, Root<?> root) {
+		super(tree, returnedType, provider, templates, metamodel);
 
-		CriteriaQuery<? extends Object> select = query.select(getCountQuery(builder, root));
-		return predicate == null ? select : select.where(predicate);
+		this.distinct = tree.isDistinct();
+		this.returnedType = returnedType;
 	}
 
-	@SuppressWarnings("rawtypes")
-	private Expression getCountQuery(CriteriaBuilder builder, Root<?> root) {
-		return distinct ? builder.countDistinct(root) : builder.count(root);
+	@Override
+	protected JpqlQueryBuilder.Select buildQuery(Sort sort) {
+		JpqlQueryBuilder.SelectStep selectStep = JpqlQueryBuilder.selectFrom(returnedType.getDomainType());
+		if (this.distinct) {
+			selectStep = selectStep.distinct();
+		}
+
+		return selectStep.count();
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityGraph.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityGraph.java
index 3a7d9421b8..cf85c65d47 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityGraph.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityGraph.java
@@ -18,8 +18,9 @@
 import java.util.List;
 
 import org.springframework.data.jpa.repository.EntityGraph;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.EntityGraph.EntityGraphType;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 
@@ -56,7 +57,7 @@ public JpaEntityGraph(EntityGraph entityGraph, String nameFallback) {
 	 * @param attributePaths may be {@literal null}.
 	 * @since 1.9
 	 */
-	public JpaEntityGraph(String name, EntityGraphType type, @Nullable String[] attributePaths) {
+	public JpaEntityGraph(String name, EntityGraphType type, String @Nullable[] attributePaths) {
 
 		Assert.hasText(name, "The name of an EntityGraph must not be null or empty");
 		Assert.notNull(type, "FetchGraphType must not be null");
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java
index c6f3d9b4e6..776657b2af 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java
@@ -15,19 +15,22 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import jakarta.persistence.criteria.CriteriaBuilder;
-import jakarta.persistence.criteria.CriteriaQuery;
-import jakarta.persistence.criteria.Predicate;
-import jakarta.persistence.criteria.Root;
+import jakarta.persistence.EntityManager;
 
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.jspecify.annotations.Nullable;
 
 import org.springframework.data.domain.KeysetScrollPosition;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jpa.repository.support.JpaEntityInformation;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.repository.query.parser.PartTree;
-import org.springframework.lang.Nullable;
 
 /**
  * Extension to {@link JpaQueryCreator} to create queries considering {@link KeysetScrollPosition keyset scrolling}.
@@ -39,35 +42,100 @@ class JpaKeysetScrollQueryCreator extends JpaQueryCreator {
 
 	private final JpaEntityInformation<?, ?> entityInformation;
 	private final KeysetScrollPosition scrollPosition;
+	private final ParameterMetadataProvider provider;
+	private final List<ParameterBinding> syntheticBindings = new ArrayList<>();
 
-	public JpaKeysetScrollQueryCreator(PartTree tree, ReturnedType type, CriteriaBuilder builder,
-			ParameterMetadataProvider provider, JpaEntityInformation<?, ?> entityInformation,
-			KeysetScrollPosition scrollPosition) {
+	public JpaKeysetScrollQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvider provider,
+			JpqlQueryTemplates templates, JpaEntityInformation<?, ?> entityInformation, KeysetScrollPosition scrollPosition,
+			EntityManager em) {
 
-		super(tree, type, builder, provider);
+		super(tree, type, provider, templates, em);
 
 		this.entityInformation = entityInformation;
 		this.scrollPosition = scrollPosition;
+		this.provider = provider;
 	}
 
 	@Override
-	protected CriteriaQuery<?> complete(@Nullable Predicate predicate, Sort sort, CriteriaQuery<?> query,
-			CriteriaBuilder builder, Root<?> root) {
+	public List<ParameterBinding> getBindings() {
+
+		List<ParameterBinding> partTreeBindings = super.getBindings();
+		List<ParameterBinding> bindings = new ArrayList<>(partTreeBindings.size() + this.syntheticBindings.size());
+		bindings.addAll(partTreeBindings);
+		bindings.addAll(this.syntheticBindings);
+
+		return bindings;
+	}
+
+	@Override
+	protected JpqlQueryBuilder.AbstractJpqlQuery createQuery(JpqlQueryBuilder.@Nullable Predicate predicate, Sort sort) {
 
 		KeysetScrollSpecification<Object> keysetSpec = new KeysetScrollSpecification<>(scrollPosition, sort,
 				entityInformation);
-		Predicate keysetPredicate = keysetSpec.createPredicate(root, builder);
 
-		CriteriaQuery<?> queryToUse = super.complete(predicate, keysetSpec.sort(), query, builder, root);
+		JpqlQueryBuilder.Select query = buildQuery(keysetSpec.sort());
+
+		Map<String, Map<Object, ParameterBinding>> cachedBindings = new LinkedHashMap<>();
+		JpqlQueryBuilder.Predicate keysetPredicate = keysetSpec.createJpqlPredicate(getFrom(), getEntity(),
+				(property, value) -> {
+
+					Map<Object, ParameterBinding> bindings = cachedBindings.computeIfAbsent(property, k -> new LinkedHashMap<>());
+
+					ParameterBinding parameterBinding = bindings.computeIfAbsent(value, o -> {
+
+						ParameterBinding binding = provider.nextSynthetic(sanitize(property), value, scrollPosition);
+						syntheticBindings.add(binding);
+						return binding;
+					});
+
+					return placeholder(parameterBinding);
+				});
+
+		JpqlQueryBuilder.Predicate predicateToUse = getPredicate(predicate, keysetPredicate);
+
+		if (predicateToUse != null) {
+			return query.where(predicateToUse);
+		}
+
+		return query;
+	}
+
+	private static String sanitize(String property) {
+
+		StringBuilder buffer = new StringBuilder(10 + property.length());
+
+		// max length 24
+		buffer.append("keyset_");
+
+		char[] charArray = property.toCharArray();
+		for (int i = 0; i < charArray.length; i++) {
+
+			if (buffer.length() > 24) {
+				break;
+			}
+
+			if (Character.isDigit(charArray[i]) || Character.isLetter(charArray[i])) {
+				buffer.append(charArray[i]);
+			} else if (charArray[i] == '.') {
+				buffer.append('_');
+			}
+		}
+
+		return buffer.toString();
+	}
+
+	private static JpqlQueryBuilder.@Nullable Predicate getPredicate(JpqlQueryBuilder.@Nullable Predicate predicate,
+			JpqlQueryBuilder.@Nullable Predicate keysetPredicate) {
 
 		if (keysetPredicate != null) {
-			if (queryToUse.getRestriction() != null) {
-				return queryToUse.where(builder.and(queryToUse.getRestriction(), keysetPredicate));
+			if (predicate != null) {
+				return predicate.nest().and(keysetPredicate.nest());
+			} else {
+				return keysetPredicate;
 			}
-			return queryToUse.where(keysetPredicate);
 		}
 
-		return queryToUse;
+		return predicate;
 	}
 
 	@Override
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java
index b3fc5526f5..f94f4ba8c6 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java
@@ -23,13 +23,14 @@
 import java.util.function.Function;
 
 import org.springframework.core.MethodParameter;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.Temporal;
 import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter;
 import org.springframework.data.repository.query.Parameter;
 import org.springframework.data.repository.query.Parameters;
 import org.springframework.data.repository.query.ParametersSource;
 import org.springframework.data.util.TypeInformation;
-import org.springframework.lang.Nullable;
 
 /**
  * Custom extension of {@link Parameters} discovering additional query parameter annotations.
@@ -63,7 +64,7 @@ protected JpaParameters(ParametersSource parametersSource,
 		super(parametersSource, parameterFactory);
 	}
 
-	private JpaParameters(List<JpaParameter> parameters) {
+	JpaParameters(List<JpaParameter> parameters) {
 		super(parameters);
 	}
 
@@ -88,26 +89,9 @@ public boolean hasLimitingParameters() {
 	public static class JpaParameter extends Parameter {
 
 		private final @Nullable Temporal annotation;
-		private @Nullable TemporalType temporalType;
-
-		/**
-		 * Creates a new {@link JpaParameter}.
-		 *
-		 * @param parameter must not be {@literal null}.
-		 * @deprecated since 3.2.1
-		 */
-		@Deprecated(since = "3.2.1", forRemoval = true)
-		protected JpaParameter(MethodParameter parameter) {
-
-			super(parameter);
 
-			this.annotation = parameter.getParameterAnnotation(Temporal.class);
-			this.temporalType = null;
-			if (!isDateParameter() && hasTemporalParamAnnotation()) {
-				throw new IllegalArgumentException(
-						Temporal.class.getSimpleName() + " annotation is only allowed on Date parameter");
-			}
-		}
+		@SuppressWarnings("deprecation")
+		private @Nullable TemporalType temporalType;
 
 		/**
 		 * Creates a new {@link JpaParameter}.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessor.java
index e222439a22..9d22c7bbb4 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessor.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessor.java
@@ -15,11 +15,12 @@
  */
 package org.springframework.data.jpa.repository.query;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter;
 import org.springframework.data.repository.query.Parameter;
 import org.springframework.data.repository.query.Parameters;
 import org.springframework.data.repository.query.ParametersParameterAccessor;
-import org.springframework.lang.Nullable;
 
 /**
  * {@link org.springframework.data.repository.query.ParameterAccessor} based on an {@link Parameters} instance. It also
@@ -31,18 +32,24 @@
  */
 public class JpaParametersParameterAccessor extends ParametersParameterAccessor {
 
+	private final JpaParameters parameters;
+
 	/**
 	 * Creates a new {@link ParametersParameterAccessor}.
 	 *
 	 * @param parameters must not be {@literal null}.
 	 * @param values must not be {@literal null}.
 	 */
-	public JpaParametersParameterAccessor(Parameters<?, ?> parameters, Object[] values) {
+	public JpaParametersParameterAccessor(JpaParameters parameters, Object[] values) {
 		super(parameters, values);
+		this.parameters = parameters;
+	}
+
+	public JpaParameters getParameters() {
+		return parameters;
 	}
 
-	@Nullable
-	public <T> T getValue(Parameter parameter) {
+	public <T> @Nullable T getValue(Parameter parameter) {
 		return super.getValue(parameter.getIndex());
 	}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryConfiguration.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryConfiguration.java
new file mode 100644
index 0000000000..788c977f25
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryConfiguration.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright 2024 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.springframework.data.jpa.repository.query;
+
+import org.springframework.data.jpa.repository.QueryRewriter;
+import org.springframework.data.repository.query.ValueExpressionDelegate;
+
+/**
+ * Configuration object holding configuration information for JPA queries within a repository.
+ *
+ * @author Mark Paluch
+ */
+public class JpaQueryConfiguration {
+
+	private final QueryRewriterProvider queryRewriter;
+	private final QueryEnhancerSelector selector;
+	private final EscapeCharacter escapeCharacter;
+	private final ValueExpressionDelegate valueExpressionDelegate;
+
+	public JpaQueryConfiguration(QueryRewriterProvider queryRewriter, QueryEnhancerSelector selector,
+			ValueExpressionDelegate valueExpressionDelegate, EscapeCharacter escapeCharacter) {
+
+		this.queryRewriter = queryRewriter;
+		this.selector = selector;
+		this.escapeCharacter = escapeCharacter;
+		this.valueExpressionDelegate = valueExpressionDelegate;
+	}
+
+	public QueryRewriter getQueryRewriter(JpaQueryMethod queryMethod) {
+		return queryRewriter.getQueryRewriter(queryMethod);
+	}
+
+	public QueryEnhancerSelector getSelector() {
+		return selector;
+	}
+
+	public EscapeCharacter getEscapeCharacter() {
+		return escapeCharacter;
+	}
+
+	public ValueExpressionDelegate getValueExpressionDelegate() {
+		return valueExpressionDelegate;
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java
index 73bafaf249..c49baf6ff9 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryCreator.java
@@ -15,17 +15,16 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import static org.springframework.data.jpa.repository.query.QueryUtils.*;
 import static org.springframework.data.repository.query.parser.Part.Type.*;
 
-import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.EntityManager;
 import jakarta.persistence.criteria.CriteriaQuery;
 import jakarta.persistence.criteria.Expression;
-import jakarta.persistence.criteria.ParameterExpression;
-import jakarta.persistence.criteria.Path;
 import jakarta.persistence.criteria.Predicate;
-import jakarta.persistence.criteria.Root;
-import jakarta.persistence.criteria.Selection;
+import jakarta.persistence.metamodel.Attribute;
+import jakarta.persistence.metamodel.Bindable;
+import jakarta.persistence.metamodel.EntityType;
+import jakarta.persistence.metamodel.Metamodel;
 import jakarta.persistence.metamodel.SingularAttribute;
 
 import java.util.ArrayList;
@@ -34,15 +33,20 @@
 import java.util.List;
 import java.util.stream.Collectors;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.domain.Sort;
-import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata;
+import org.springframework.data.jpa.domain.JpaSort;
+import org.springframework.data.jpa.repository.query.JpqlQueryBuilder.ParameterPlaceholder;
+import org.springframework.data.jpa.repository.query.ParameterBinding.PartTreeParameterBinding;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
 import org.springframework.data.mapping.PropertyPath;
+import org.springframework.data.mapping.PropertyReferenceException;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.repository.query.parser.AbstractQueryCreator;
 import org.springframework.data.repository.query.parser.Part;
 import org.springframework.data.repository.query.parser.Part.Type;
 import org.springframework.data.repository.query.parser.PartTree;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
@@ -56,56 +60,77 @@
  * @author Moritz Becker
  * @author Andrey Kovalev
  * @author Greg Turnquist
+ * @author Christoph Strobl
  * @author Jinmyeong Kim
  */
-public class JpaQueryCreator extends AbstractQueryCreator<CriteriaQuery<? extends Object>, Predicate> {
+public class JpaQueryCreator extends AbstractQueryCreator<String, JpqlQueryBuilder.Predicate> implements JpqlQueryCreator {
 
-	private final CriteriaBuilder builder;
-	private final Root<?> root;
-	private final CriteriaQuery<? extends Object> query;
-	private final ParameterMetadataProvider provider;
 	private final ReturnedType returnedType;
+	private final ParameterMetadataProvider provider;
+	private final JpqlQueryTemplates templates;
 	private final PartTree tree;
 	private final EscapeCharacter escape;
+	private final EntityType<?> entityType;
+	private final JpqlQueryBuilder.Entity entity;
+	private final Metamodel metamodel;
+	private final boolean useNamedParameters;
 
 	/**
 	 * Create a new {@link JpaQueryCreator}.
 	 *
 	 * @param tree must not be {@literal null}.
 	 * @param type must not be {@literal null}.
-	 * @param builder must not be {@literal null}.
+	 * @param templates must not be {@literal null}.
 	 * @param provider must not be {@literal null}.
+	 * @param em must not be {@literal null}.
 	 */
-	public JpaQueryCreator(PartTree tree, ReturnedType type, CriteriaBuilder builder,
-			ParameterMetadataProvider provider) {
+	public JpaQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvider provider,
+			JpqlQueryTemplates templates, EntityManager em) {
+
+		this(tree, type, provider, templates, em.getMetamodel());
+	}
+
+	public JpaQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvider provider,
+		JpqlQueryTemplates templates, Metamodel metamodel) {
 
 		super(tree);
 		this.tree = tree;
+		this.returnedType = type;
+		this.provider = provider;
 
-		CriteriaQuery<?> criteriaQuery = createCriteriaQuery(builder, type);
+		JpaParameters bindableParameters = provider.getParameters().getBindableParameters();
 
-		this.builder = builder;
-		this.query = criteriaQuery.distinct(tree.isDistinct() && !tree.isCountProjection());
-		this.root = query.from(type.getDomainType());
-		this.provider = provider;
-		this.returnedType = type;
+		boolean useNamedParameters = false;
+		for (JpaParameters.JpaParameter bindableParameter : bindableParameters) {
+
+			if (bindableParameter.isNamedParameter()) {
+				useNamedParameters = true;
+			}
+
+			if (useNamedParameters && !bindableParameter.isNamedParameter()) {
+				useNamedParameters = false;
+				break;
+			}
+		}
+
+		this.useNamedParameters = useNamedParameters;
+		this.templates = templates;
 		this.escape = provider.getEscape();
+		this.entityType = metamodel.entity(type.getDomainType());
+		this.entity = JpqlQueryBuilder.entity(returnedType.getDomainType());
+		this.metamodel = metamodel;
 	}
 
-	/**
-	 * Creates the {@link CriteriaQuery} to apply predicates on.
-	 *
-	 * @param builder will never be {@literal null}.
-	 * @param type will never be {@literal null}.
-	 * @return must not be {@literal null}.
-	 */
-	protected CriteriaQuery<? extends Object> createCriteriaQuery(CriteriaBuilder builder, ReturnedType type) {
+	Bindable<?> getFrom() {
+		return entityType;
+	}
 
-		Class<?> typeToRead = tree.isDelete() ? type.getDomainType() : type.getTypeToRead();
+	JpqlQueryBuilder.Entity getEntity() {
+		return entity;
+	}
 
-		return (typeToRead == null) || tree.isExistsProjection() //
-				? builder.createTupleQuery() //
-				: builder.createQuery(typeToRead);
+	public boolean useTupleQuery() {
+		return returnedType.needsCustomConstruction() && returnedType.getReturnedType().isInterface();
 	}
 
 	/**
@@ -113,102 +138,176 @@ protected CriteriaQuery<? extends Object> createCriteriaQuery(CriteriaBuilder bu
 	 *
 	 * @return the parameterExpressions
 	 */
-	public List<ParameterMetadata<?>> getParameterExpressions() {
-		return provider.getExpressions();
+	public List<ParameterBinding> getBindings() {
+		return provider.getBindings();
+	}
+
+	@Override
+	public ParameterBinder getBinder() {
+		return ParameterBinderFactory.createBinder(provider.getParameters(), getBindings());
 	}
 
 	@Override
-	protected Predicate create(Part part, Iterator<Object> iterator) {
-		return toPredicate(part, root);
+	protected JpqlQueryBuilder.Predicate create(Part part, Iterator<Object> iterator) {
+		return toPredicate(part);
 	}
 
 	@Override
-	protected Predicate and(Part part, Predicate base, Iterator<Object> iterator) {
-		return builder.and(base, toPredicate(part, root));
+	protected JpqlQueryBuilder.Predicate and(Part part, JpqlQueryBuilder.Predicate base, Iterator<Object> iterator) {
+		return base.and(toPredicate(part));
 	}
 
 	@Override
-	protected Predicate or(Predicate base, Predicate predicate) {
-		return builder.or(base, predicate);
+	protected JpqlQueryBuilder.Predicate or(JpqlQueryBuilder.Predicate base, JpqlQueryBuilder.Predicate predicate) {
+		return base.or(predicate);
 	}
 
 	/**
-	 * Finalizes the given {@link Predicate} and applies the given sort. Delegates to
-	 * {@link #complete(Predicate, Sort, CriteriaQuery, CriteriaBuilder, Root)} and hands it the current
-	 * {@link CriteriaQuery} and {@link CriteriaBuilder}.
+	 * Finalizes the given {@link Predicate} and applies the given sort. Delegates to {@link #buildQuery(Sort)} and hands
+	 * it the current {@link JpqlQueryBuilder.Predicate}.
 	 */
 	@Override
-	protected final CriteriaQuery<? extends Object> complete(Predicate predicate, Sort sort) {
-		return complete(predicate, sort, query, builder, root);
+	protected final String complete(JpqlQueryBuilder.@Nullable Predicate predicate, Sort sort) {
+
+		JpqlQueryBuilder.AbstractJpqlQuery query = createQuery(predicate, sort);
+		return query.render();
+	}
+
+	protected JpqlQueryBuilder.AbstractJpqlQuery createQuery(JpqlQueryBuilder.@Nullable Predicate predicate, Sort sort) {
+
+		JpqlQueryBuilder.Select query = buildQuery(sort);
+
+		if (predicate != null) {
+			return query.where(predicate);
+		}
+
+		return query;
 	}
 
 	/**
-	 * Template method to finalize the given {@link Predicate} using the given {@link CriteriaQuery} and
-	 * {@link CriteriaBuilder}.
+	 * Template method to build a query stub using the given {@link Sort}.
 	 *
-	 * @param predicate
 	 * @param sort
-	 * @param query
-	 * @param builder
 	 * @return
 	 */
-	@SuppressWarnings({ "unchecked", "rawtypes" })
-	protected CriteriaQuery<? extends Object> complete(@Nullable Predicate predicate, Sort sort,
-			CriteriaQuery<? extends Object> query, CriteriaBuilder builder, Root<?> root) {
+	protected JpqlQueryBuilder.Select buildQuery(Sort sort) {
 
-		if (returnedType.needsCustomConstruction()) {
+		JpqlQueryBuilder.Select select = doSelect(sort);
+
+		if (tree.isDelete() || tree.isCountProjection()) {
+			return select;
+		}
+
+		for (Sort.Order order : sort) {
 
-			Collection<String> requiredSelection = getRequiredSelection(sort, returnedType);
-			List<Selection<?>> selections = new ArrayList<>();
+			JpqlQueryBuilder.Expression expression;
+			QueryUtils.checkSortExpression(order);
 
-			for (String property : requiredSelection) {
+			try {
+				expression = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
+						PropertyPath.from(order.getProperty(), entityType.getJavaType()));
+			} catch (PropertyReferenceException e) {
 
-				PropertyPath path = PropertyPath.from(property, returnedType.getDomainType());
-				selections.add(toExpressionRecursively(root, path, true).alias(property));
+				if (order instanceof JpaSort.JpaOrder jpaOrder && jpaOrder.isUnsafe()) {
+					expression = JpqlQueryBuilder.expression(order.getProperty());
+				} else {
+					throw e;
+				}
 			}
 
-			Class<?> typeToRead = returnedType.getReturnedType();
+			if (order.isIgnoreCase()) {
+				expression = JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expression);
+			}
 
-			query = typeToRead.isInterface() //
-					? query.multiselect(selections) //
-					: query.select((Selection) builder.construct(typeToRead, //
-							selections.toArray(new Selection[0])));
+			select.orderBy(JpqlQueryBuilder.orderBy(expression, order));
+		}
+
+		return select;
+	}
 
-		} else if (tree.isExistsProjection()) {
+	private JpqlQueryBuilder.Select doSelect(Sort sort) {
 
-			if (root.getModel().hasSingleIdAttribute()) {
+		JpqlQueryBuilder.SelectStep selectStep = JpqlQueryBuilder.selectFrom(entity);
 
-				SingularAttribute<?, ?> id = root.getModel().getId(root.getModel().getIdType().getJavaType());
-				query = query.multiselect(root.get((SingularAttribute) id).alias(id.getName()));
+		if (tree.isDelete()) {
+			return selectStep.entity();
+		}
+
+		if (tree.isDistinct()) {
+			selectStep = selectStep.distinct();
+		}
 
+		if (returnedType.needsCustomConstruction()) {
+
+			Collection<String> requiredSelection = null;
+			if (returnedType.getReturnedType().getPackageName().startsWith("java.util")
+					|| returnedType.getReturnedType().getPackageName().startsWith("jakarta.persistence")) {
+				requiredSelection = metamodel.managedType(returnedType.getDomainType()).getAttributes().stream()
+						.map(Attribute::getName).collect(Collectors.toList());
 			} else {
+				requiredSelection = getRequiredSelection(sort, returnedType);
+			}
 
-				query = query.multiselect(root.getModel().getIdClassAttributes().stream()//
-						.map(it -> (Selection<?>) root.get((SingularAttribute) it).alias(it.getName()))
-						.collect(Collectors.toList()));
+			List<JpqlQueryBuilder.PathExpression> paths = new ArrayList<>(requiredSelection.size());
+			for (String selection : requiredSelection) {
+				paths.add(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
+						PropertyPath.from(selection, returnedType.getDomainType()), true));
 			}
 
-		} else {
-			query = query.select((Root) root);
+			if (useTupleQuery()) {
+
+				return selectStep.select(paths);
+			} else {
+				return selectStep.instantiate(returnedType.getReturnedType(), paths);
+			}
+		}
+
+		if (tree.isExistsProjection()) {
+
+			if (entityType.hasSingleIdAttribute()) {
+
+				SingularAttribute<?, ?> id = entityType.getId(entityType.getIdType().getJavaType());
+				return selectStep.select(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
+						PropertyPath.from(id.getName(), returnedType.getDomainType()), true));
+
+			} else {
+
+				List<JpqlQueryBuilder.PathExpression> paths = entityType.getIdClassAttributes().stream()//
+						.map(it -> JpqlUtils.toExpressionRecursively(metamodel, entity, entityType,
+								PropertyPath.from(it.getName(), returnedType.getDomainType()), true))
+						.toList();
+				return selectStep.select(paths);
+			}
 		}
 
-		CriteriaQuery<? extends Object> select = query.orderBy(QueryUtils.toOrders(sort, root, builder));
-		return predicate == null ? select : select.where(predicate);
+		if (tree.isCountProjection()) {
+			return selectStep.count();
+		} else {
+			return selectStep.entity();
+		}
 	}
 
 	Collection<String> getRequiredSelection(Sort sort, ReturnedType returnedType) {
 		return returnedType.getInputProperties();
 	}
 
+	JpqlQueryBuilder.Expression placeholder(ParameterBinding binding) {
+
+		if (useNamedParameters && binding.hasName()) {
+			return JpqlQueryBuilder.parameter(ParameterPlaceholder.named(binding.getRequiredName()));
+		}
+
+		return JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(binding.getRequiredPosition()));
+	}
+
 	/**
 	 * Creates a {@link Predicate} from the given {@link Part}.
 	 *
 	 * @param part
-	 * @param root
 	 * @return
 	 */
-	private Predicate toPredicate(Part part, Root<?> root) {
-		return new PredicateBuilder(part, root).build();
+	private JpqlQueryBuilder.Predicate toPredicate(Part part) {
+		return new PredicateBuilder(part).build();
 	}
 
 	/**
@@ -217,24 +316,20 @@ private Predicate toPredicate(Part part, Root<?> root) {
 	 * @author Phil Webb
 	 * @author Oliver Gierke
 	 */
-	@SuppressWarnings({ "unchecked", "rawtypes" })
 	private class PredicateBuilder {
 
 		private final Part part;
-		private final Root<?> root;
 
 		/**
-		 * Creates a new {@link PredicateBuilder} for the given {@link Part} and {@link Root}.
+		 * Creates a new {@link PredicateBuilder} for the given {@link Part}.
 		 *
 		 * @param part must not be {@literal null}.
-		 * @param root must not be {@literal null}.
 		 */
-		public PredicateBuilder(Part part, Root<?> root) {
+		public PredicateBuilder(Part part) {
 
 			Assert.notNull(part, "Part must not be null");
-			Assert.notNull(root, "Root must not be null");
+
 			this.part = part;
-			this.root = root;
 		}
 
 		/**
@@ -242,83 +337,78 @@ public PredicateBuilder(Part part, Root<?> root) {
 		 *
 		 * @return
 		 */
-		public Predicate build() {
+		public JpqlQueryBuilder.Predicate build() {
 
 			PropertyPath property = part.getProperty();
 			Type type = part.getType();
 
+			JpqlQueryBuilder.PathExpression pas = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, property);
+			JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(pas);
+			JpqlQueryBuilder.WhereStep whereIgnoreCase = JpqlQueryBuilder.where(potentiallyIgnoreCase(pas));
+
 			switch (type) {
 				case BETWEEN:
-					ParameterMetadata<Comparable> first = provider.next(part);
-					ParameterMetadata<Comparable> second = provider.next(part);
-					return builder.between(getComparablePath(root, part), first.getExpression(), second.getExpression());
+					PartTreeParameterBinding first = provider.next(part);
+					ParameterBinding second = provider.next(part);
+					return where.between(placeholder(first), placeholder(second));
 				case AFTER:
 				case GREATER_THAN:
-					return builder.greaterThan(getComparablePath(root, part),
-							provider.next(part, Comparable.class).getExpression());
+					return where.gt(placeholder(provider.next(part)));
 				case GREATER_THAN_EQUAL:
-					return builder.greaterThanOrEqualTo(getComparablePath(root, part),
-							provider.next(part, Comparable.class).getExpression());
+					return where.gte(placeholder(provider.next(part)));
 				case BEFORE:
 				case LESS_THAN:
-					return builder.lessThan(getComparablePath(root, part), provider.next(part, Comparable.class).getExpression());
+					return where.lt(placeholder(provider.next(part)));
 				case LESS_THAN_EQUAL:
-					return builder.lessThanOrEqualTo(getComparablePath(root, part),
-							provider.next(part, Comparable.class).getExpression());
+					return where.lte(placeholder(provider.next(part)));
 				case IS_NULL:
-					return getTypedPath(root, part).isNull();
+					return where.isNull();
 				case IS_NOT_NULL:
-					return getTypedPath(root, part).isNotNull();
+					return where.isNotNull();
 				case NOT_IN:
-					// cast required for eclipselink workaround, see DATAJPA-433
-					return upperIfIgnoreCase(getTypedPath(root, part))
-							.in((Expression<Collection<?>>) provider.next(part, Collection.class).getExpression()).not();
+					return whereIgnoreCase.notIn(placeholder(provider.next(part, Collection.class)));
 				case IN:
-					// cast required for eclipselink workaround, see DATAJPA-433
-					return upperIfIgnoreCase(getTypedPath(root, part))
-							.in((Expression<Collection<?>>) provider.next(part, Collection.class).getExpression());
+					return whereIgnoreCase.in(placeholder(provider.next(part, Collection.class)));
 				case STARTING_WITH:
 				case ENDING_WITH:
 				case CONTAINING:
 				case NOT_CONTAINING:
 
 					if (property.getLeafProperty().isCollection()) {
+						where = JpqlQueryBuilder.where(entity, property);
 
-						Expression<Collection<Object>> propertyExpression = traversePath(root, property);
-						ParameterExpression<Object> parameterExpression = provider.next(part).getExpression();
-
-						// Can't just call .not() in case of negation as EclipseLink chokes on that.
-						return type.equals(NOT_CONTAINING) //
-								? isNotMember(builder, parameterExpression, propertyExpression) //
-								: isMember(builder, parameterExpression, propertyExpression);
+						return type.equals(NOT_CONTAINING) ? where.notMemberOf(placeholder(provider.next(part)))
+								: where.memberOf(placeholder(provider.next(part)));
 					}
 
 				case LIKE:
 				case NOT_LIKE:
-					Expression<String> stringPath = getTypedPath(root, part);
-					Expression<String> propertyExpression = upperIfIgnoreCase(stringPath);
-					Expression<String> parameterExpression = upperIfIgnoreCase(provider.next(part, String.class).getExpression());
-					Predicate like = builder.like(propertyExpression, parameterExpression, escape.getEscapeCharacter());
-					return type.equals(NOT_LIKE) || type.equals(NOT_CONTAINING) ? like.not() : like;
+
+					PartTreeParameterBinding parameter = provider.next(part, String.class);
+					JpqlQueryBuilder.Expression parameterExpression = potentiallyIgnoreCase(part.getProperty(),
+							placeholder(parameter));
+					// Predicate like = builder.like(propertyExpression, parameterExpression, escape.getEscapeCharacter());
+					String escapeChar = Character.toString(escape.getEscapeCharacter());
+					return
+
+					type.equals(NOT_LIKE) || type.equals(NOT_CONTAINING)
+							? whereIgnoreCase.notLike(parameterExpression, escapeChar)
+							: whereIgnoreCase.like(parameterExpression, escapeChar);
 				case TRUE:
-					Expression<Boolean> truePath = getTypedPath(root, part);
-					return builder.isTrue(truePath);
+					return where.isTrue();
 				case FALSE:
-					Expression<Boolean> falsePath = getTypedPath(root, part);
-					return builder.isFalse(falsePath);
+					return where.isFalse();
 				case SIMPLE_PROPERTY:
 				case NEGATING_SIMPLE_PROPERTY:
 
-					ParameterMetadata<Object> expression = provider.next(part);
-					Expression<Object> path = getTypedPath(root, part);
+					PartTreeParameterBinding simple = provider.next(part);
 
-					if (expression.isIsNullParameter()) {
-						return type.equals(SIMPLE_PROPERTY) ? path.isNull() : path.isNotNull();
-					} else {
-						return type.equals(SIMPLE_PROPERTY)
-								? builder.equal(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression()))
-								: builder.notEqual(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression()));
+					if (simple.isIsNullParameter()) {
+						return type.equals(SIMPLE_PROPERTY) ? where.isNull() : where.isNotNull();
 					}
+
+					JpqlQueryBuilder.Expression expression = potentiallyIgnoreCase(property, placeholder(simple));
+					return type.equals(SIMPLE_PROPERTY) ? whereIgnoreCase.eq(expression) : whereIgnoreCase.neq(expression);
 				case IS_EMPTY:
 				case IS_NOT_EMPTY:
 
@@ -326,77 +416,69 @@ public Predicate build() {
 						throw new IllegalArgumentException("IsEmpty / IsNotEmpty can only be used on collection properties");
 					}
 
-					Expression<Collection<Object>> collectionPath = traversePath(root, property);
-					return type.equals(IS_NOT_EMPTY) ? builder.isNotEmpty(collectionPath) : builder.isEmpty(collectionPath);
+					where = JpqlQueryBuilder.where(entity, property);
+					return type.equals(IS_NOT_EMPTY) ? where.isNotEmpty() : where.isEmpty();
 
 				default:
 					throw new IllegalArgumentException("Unsupported keyword " + type);
 			}
 		}
 
-		private <T> Predicate isMember(CriteriaBuilder builder, Expression<T> parameter,
-				Expression<Collection<T>> property) {
-			return builder.isMember(parameter, property);
+		/**
+		 * Applies an {@code UPPERCASE} conversion to the given {@link Expression} in case the underlying {@link Part}
+		 * requires ignoring case.
+		 *
+		 * @param path must not be {@literal null}.
+		 * @return
+		 */
+		private <T> JpqlQueryBuilder.Expression potentiallyIgnoreCase(JpqlQueryBuilder.Origin source, PropertyPath path) {
+			return potentiallyIgnoreCase(path, JpqlQueryBuilder.expression(source, path));
 		}
 
-		private <T> Predicate isNotMember(CriteriaBuilder builder, Expression<T> parameter,
-				Expression<Collection<T>> property) {
-			return builder.isNotMember(parameter, property);
+		/**
+		 * Applies an {@code UPPERCASE} conversion to the given {@link Expression} in case the underlying {@link Part}
+		 * requires ignoring case.
+		 *
+		 * @param path must not be {@literal null}.
+		 * @return
+		 */
+		private <T> JpqlQueryBuilder.Expression potentiallyIgnoreCase(JpqlQueryBuilder.PathExpression path) {
+			return potentiallyIgnoreCase(path.getPropertyPath(), path);
 		}
 
 		/**
 		 * Applies an {@code UPPERCASE} conversion to the given {@link Expression} in case the underlying {@link Part}
 		 * requires ignoring case.
 		 *
-		 * @param expression must not be {@literal null}.
 		 * @return
 		 */
-		private <T> Expression<T> upperIfIgnoreCase(Expression<? extends T> expression) {
+		private <T> JpqlQueryBuilder.Expression potentiallyIgnoreCase(PropertyPath path,
+				JpqlQueryBuilder.Expression expressionValue) {
 
 			switch (part.shouldIgnoreCase()) {
 
 				case ALWAYS:
 
-					Assert.state(canUpperCase(expression), "Unable to ignore case of " + expression.getJavaType().getName()
+					Assert.isTrue(canUpperCase(path), "Unable to ignore case of " + path.getType().getName()
 							+ " types, the property '" + part.getProperty().getSegment() + "' must reference a String");
-					return (Expression<T>) builder.upper((Expression<String>) expression);
+					return JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expressionValue);
 
 				case WHEN_POSSIBLE:
 
-					if (canUpperCase(expression)) {
-						return (Expression<T>) builder.upper((Expression<String>) expression);
+					if (canUpperCase(path)) {
+						return JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expressionValue);
 					}
 
 				case NEVER:
 				default:
 
-					return (Expression<T>) expression;
+					return expressionValue;
 			}
 		}
 
-		private boolean canUpperCase(Expression<?> expression) {
-			return String.class.equals(expression.getJavaType());
-		}
-
-		/**
-		 * Returns a path to a {@link Comparable}.
-		 *
-		 * @param root
-		 * @param part
-		 * @return
-		 */
-		private Expression<? extends Comparable> getComparablePath(Root<?> root, Part part) {
-			return getTypedPath(root, part);
-		}
-
-		private <T> Expression<T> getTypedPath(Root<?> root, Part part) {
-			return toExpressionRecursively(root, part.getProperty());
-		}
-
-		private <T> Expression<T> traversePath(Path<?> root, PropertyPath path) {
-
-			Path<Object> result = root.get(path.getSegment());
-			return (Expression<T>) (path.hasNext() ? traversePath(result, path.next()) : result);
+		private boolean canUpperCase(PropertyPath path) {
+			return String.class.equals(path.getType());
 		}
 	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java
index d6d4cbeda1..04d134c0ad 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java
@@ -16,7 +16,6 @@
 package org.springframework.data.jpa.repository.query;
 
 import java.util.List;
-import java.util.Set;
 import java.util.function.BiFunction;
 import java.util.function.Function;
 
@@ -32,11 +31,10 @@
 import org.antlr.v4.runtime.atn.PredictionMode;
 import org.antlr.v4.runtime.misc.ParseCancellationException;
 import org.antlr.v4.runtime.tree.ParseTreeVisitor;
+import org.jspecify.annotations.Nullable;
 
 import org.springframework.data.domain.Sort;
 import org.springframework.data.repository.query.ReturnedType;
-import org.springframework.lang.Nullable;
-import org.springframework.util.Assert;
 
 /**
  * Implementation of {@link QueryEnhancer} to enhance JPA queries using ANTLR parsers.
@@ -55,11 +53,11 @@ class JpaQueryEnhancer<Q extends QueryInformation> implements QueryEnhancer {
 	private final Q queryInformation;
 	private final String projection;
 	private final SortedQueryRewriteFunction<Q> sortFunction;
-	private final BiFunction<String, Q, ParseTreeVisitor<QueryTokenStream>> countQueryFunction;
+	private final BiFunction<@Nullable String, Q, ParseTreeVisitor<QueryTokenStream>> countQueryFunction;
 
 	JpaQueryEnhancer(ParserRuleContext context, ParsedQueryIntrospector<Q> introspector,
 			SortedQueryRewriteFunction<Q> sortFunction,
-			BiFunction<String, Q, ParseTreeVisitor<QueryTokenStream>> countQueryFunction) {
+			BiFunction<@Nullable String, Q, ParseTreeVisitor<QueryTokenStream>> countQueryFunction) {
 
 		this.context = context;
 		this.sortFunction = sortFunction;
@@ -142,43 +140,34 @@ static void configureParser(String query, String grammar, Lexer lexer, Parser pa
 	}
 
 	/**
-	 * Factory method to create a {@link JpaQueryEnhancer} for {@link DeclaredQuery} using JPQL grammar.
+	 * Factory method to create a {@link JpaQueryEnhancer} for {@link ParametrizedQuery} using JPQL grammar.
 	 *
 	 * @param query must not be {@literal null}.
 	 * @return a new {@link JpaQueryEnhancer} using JPQL.
 	 */
-	public static JpaQueryEnhancer<QueryInformation> forJpql(DeclaredQuery query) {
-
-		Assert.notNull(query, "DeclaredQuery must not be null!");
-
-		return JpqlQueryParser.parseQuery(query.getQueryString());
+	public static JpaQueryEnhancer<QueryInformation> forJpql(String query) {
+		return JpqlQueryParser.parseQuery(query);
 	}
 
 	/**
-	 * Factory method to create a {@link JpaQueryEnhancer} for {@link DeclaredQuery} using HQL grammar.
+	 * Factory method to create a {@link JpaQueryEnhancer} for {@link ParametrizedQuery} using HQL grammar.
 	 *
 	 * @param query must not be {@literal null}.
 	 * @return a new {@link JpaQueryEnhancer} using HQL.
 	 */
-	public static JpaQueryEnhancer<HibernateQueryInformation> forHql(DeclaredQuery query) {
-
-		Assert.notNull(query, "DeclaredQuery must not be null!");
-
-		return HqlQueryParser.parseQuery(query.getQueryString());
+	public static JpaQueryEnhancer<HibernateQueryInformation> forHql(String query) {
+		return HqlQueryParser.parseQuery(query);
 	}
 
 	/**
-	 * Factory method to create a {@link JpaQueryEnhancer} for {@link DeclaredQuery} using EQL grammar.
+	 * Factory method to create a {@link JpaQueryEnhancer} for {@link ParametrizedQuery} using EQL grammar.
 	 *
 	 * @param query must not be {@literal null}.
 	 * @return a new {@link JpaQueryEnhancer} using EQL.
 	 * @since 3.2
 	 */
-	public static JpaQueryEnhancer<QueryInformation> forEql(DeclaredQuery query) {
-
-		Assert.notNull(query, "DeclaredQuery must not be null!");
-
-		return EqlQueryParser.parseQuery(query.getQueryString());
+	public static JpaQueryEnhancer<QueryInformation> forEql(String query) {
+		return EqlQueryParser.parseQuery(query);
 	}
 
 	/**
@@ -206,33 +195,21 @@ public boolean hasConstructorExpression() {
 	}
 
 	/**
-	 * Resolves the alias for the entity in the FROM clause from the JPA query. Since the {@link JpaQueryParser} can
-	 * already find the alias when generating sorted and count queries, this is mainly to serve test cases.
+	 * Resolves the alias for the entity in the FROM clause from the JPA query.
 	 */
 	@Override
-	public String detectAlias() {
+	public @Nullable String detectAlias() {
 		return this.queryInformation.getAlias();
 	}
 
 	/**
-	 * Looks up the projection of the JPA query. Since the {@link JpaQueryParser} can already find the projection when
-	 * generating sorted and count queries, this is mainly to serve test cases.
+	 * Looks up the projection of the JPA query.
 	 */
 	@Override
 	public String getProjection() {
 		return this.projection;
 	}
 
-	/**
-	 * Since the parser can already fully transform sorted and count queries by itself, this is a placeholder method.
-	 *
-	 * @return empty set
-	 */
-	@Override
-	public Set<String> getJoinAliases() {
-		return Set.of();
-	}
-
 	/**
 	 * Look up the {@link DeclaredQuery} from the query parser.
 	 */
@@ -241,17 +218,6 @@ public DeclaredQuery getQuery() {
 		throw new UnsupportedOperationException();
 	}
 
-	/**
-	 * Adds an {@literal order by} clause to the JPA query.
-	 *
-	 * @param sort the sort specification to apply.
-	 * @return
-	 */
-	@Override
-	public String applySorting(Sort sort) {
-		return QueryRenderer.TokenRenderer.render(sortFunction.apply(sort, this.queryInformation, null).visit(context));
-	}
-
 	@Override
 	public String rewrite(QueryRewriteInformation rewriteInformation) {
 		return QueryRenderer.TokenRenderer.render(
@@ -259,28 +225,6 @@ public String rewrite(QueryRewriteInformation rewriteInformation) {
 						.visit(context));
 	}
 
-	/**
-	 * Because the parser can find the alias of the FROM clause, there is no need to "find it" in advance.
-	 *
-	 * @param sort the sort specification to apply.
-	 * @param alias IGNORED
-	 * @return
-	 */
-	@Override
-	public String applySorting(Sort sort, String alias) {
-		return applySorting(sort);
-	}
-
-	/**
-	 * Creates a count query from the original query, with no count projection.
-	 *
-	 * @return Guaranteed to be not {@literal null};
-	 */
-	@Override
-	public String createCountQueryFor() {
-		return createCountQueryFor(null);
-	}
-
 	/**
 	 * Create a count query from the original query, with potential custom projection.
 	 *
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java
index 35a680c8fe..be0a09bc4c 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java
@@ -16,7 +16,6 @@
 package org.springframework.data.jpa.repository.query;
 
 import jakarta.persistence.EntityManager;
-import jakarta.persistence.NoResultException;
 import jakarta.persistence.Query;
 import jakarta.persistence.StoredProcedureQuery;
 
@@ -26,6 +25,8 @@
 import java.util.Map;
 import java.util.Optional;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.core.convert.ConversionService;
 import org.springframework.core.convert.support.ConfigurableConversionService;
 import org.springframework.core.convert.support.DefaultConversionService;
@@ -40,7 +41,6 @@
 import org.springframework.data.support.PageableExecutionUtils;
 import org.springframework.data.util.CloseableIterator;
 import org.springframework.data.util.StreamUtils;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
 import org.springframework.util.ReflectionUtils;
@@ -81,19 +81,12 @@ public abstract class JpaQueryExecution {
 	 * @param accessor must not be {@literal null}.
 	 * @return
 	 */
-	@Nullable
-	public Object execute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) {
+	public @Nullable Object execute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) {
 
 		Assert.notNull(query, "AbstractJpaQuery must not be null");
 		Assert.notNull(accessor, "JpaParametersParameterAccessor must not be null");
 
-		Object result;
-
-		try {
-			result = doExecute(query, accessor);
-		} catch (NoResultException e) {
-			return null;
-		}
+		Object result = doExecute(query, accessor);
 
 		if (result == null) {
 			return null;
@@ -117,8 +110,7 @@ public Object execute(AbstractJpaQuery query, JpaParametersParameterAccessor acc
 	 * @param query must not be {@literal null}.
 	 * @param accessor must not be {@literal null}.
 	 */
-	@Nullable
-	protected abstract Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor);
+	protected abstract @Nullable Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor);
 
 	/**
 	 * Executes the query to return a simple collection of entities.
@@ -149,7 +141,7 @@ static class ScrollExecution extends JpaQueryExecution {
 		}
 
 		@Override
-		@SuppressWarnings("unchecked")
+		@SuppressWarnings("NullAway")
 		protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) {
 
 			ScrollPosition scrollPosition = accessor.getScrollPosition();
@@ -196,6 +188,12 @@ protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccesso
 	 */
 	static class PagedExecution extends JpaQueryExecution {
 
+		private final PersistenceProvider provider;
+
+		PagedExecution(PersistenceProvider provider) {
+			this.provider = provider;
+		}
+
 		@Override
 		@SuppressWarnings("unchecked")
 		protected Object doExecute(AbstractJpaQuery repositoryQuery, JpaParametersParameterAccessor accessor) {
@@ -203,13 +201,34 @@ protected Object doExecute(AbstractJpaQuery repositoryQuery, JpaParametersParame
 			Query query = repositoryQuery.createQuery(accessor);
 
 			return PageableExecutionUtils.getPage(query.getResultList(), accessor.getPageable(),
-					() -> count(repositoryQuery, accessor));
+					() -> count(query, repositoryQuery, accessor));
+		}
+
+		private long count(Query resultQuery, AbstractJpaQuery repositoryQuery, JpaParametersParameterAccessor accessor) {
+
+			if (repositoryQuery.hasDeclaredCountQuery()) {
+				return doCount(repositoryQuery, accessor);
+			}
+
+			return provider.getResultCount(resultQuery, () -> doCount(repositoryQuery, accessor));
 		}
 
-		private long count(AbstractJpaQuery repositoryQuery, JpaParametersParameterAccessor accessor) {
+		long doCount(AbstractJpaQuery repositoryQuery, JpaParametersParameterAccessor accessor) {
 
 			List<?> totals = repositoryQuery.createCountQuery(accessor).getResultList();
-			return (totals.size() == 1 ? CONVERSION_SERVICE.convert(totals.get(0), Long.class) : totals.size());
+
+			if (totals.size() == 1) {
+				Object result = totals.get(0);
+
+				if (result instanceof Number n) {
+					return n.longValue();
+				}
+
+				return CONVERSION_SERVICE.convert(result, Long.class);
+			}
+
+			// group by count
+			return totals.size();
 		}
 	}
 
@@ -219,9 +238,9 @@ private long count(AbstractJpaQuery repositoryQuery, JpaParametersParameterAcces
 	static class SingleEntityExecution extends JpaQueryExecution {
 
 		@Override
-		protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) {
+		protected @Nullable Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) {
 
-			return query.createQuery(accessor).getSingleResult();
+			return query.createQuery(accessor).getSingleResultOrNull();
 		}
 	}
 
@@ -233,6 +252,7 @@ static class ModifyingExecution extends JpaQueryExecution {
 		private final EntityManager em;
 		private final boolean flush;
 		private final boolean clear;
+		private final JpaQueryMethod method;
 
 		/**
 		 * Creates an execution that automatically flushes the given {@link EntityManager} before execution and/or clears
@@ -241,6 +261,7 @@ static class ModifyingExecution extends JpaQueryExecution {
 		 * @param em Must not be {@literal null}.
 		 */
 		public ModifyingExecution(JpaQueryMethod method, EntityManager em) {
+			this.method = method;
 
 			Assert.notNull(em, "The EntityManager must not be null");
 
@@ -248,8 +269,9 @@ public ModifyingExecution(JpaQueryMethod method, EntityManager em) {
 
 			boolean isVoid = ClassUtils.isAssignable(returnType, Void.class);
 			boolean isInt = ClassUtils.isAssignable(returnType, Integer.class);
+			boolean isLong = ClassUtils.isAssignable(returnType, Long.class);
 
-			Assert.isTrue(isInt || isVoid,
+			Assert.isTrue(isInt || isLong || isVoid,
 					"Modifying queries can only use void or int/Integer as return type; Offending method: " + method);
 
 			this.em = em;
@@ -270,6 +292,10 @@ protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccesso
 				em.clear();
 			}
 
+			if (ClassUtils.isAssignable(method.getReturnType(), Long.class)) {
+				return (long) result;
+			}
+
 			return result;
 		}
 	}
@@ -334,7 +360,7 @@ static class ProcedureExecution extends JpaQueryExecution {
 		}
 
 		@Override
-		protected Object doExecute(AbstractJpaQuery jpaQuery, JpaParametersParameterAccessor accessor) {
+		protected @Nullable Object doExecute(AbstractJpaQuery jpaQuery, JpaParametersParameterAccessor accessor) {
 
 			Assert.isInstanceOf(StoredProcedureJpaQuery.class, jpaQuery);
 
@@ -379,10 +405,10 @@ static class StreamExecution extends JpaQueryExecution {
 
 		private static final String NO_SURROUNDING_TRANSACTION = "You're trying to execute a streaming query method without a surrounding transaction that keeps the connection open so that the Stream can actually be consumed; Make sure the code consuming the stream uses @Transactional or any other way of declaring a (read-only) transaction";
 
-		private static final Method streamMethod = ReflectionUtils.findMethod(Query.class, "getResultStream");
+		private static final @Nullable Method streamMethod = ReflectionUtils.findMethod(Query.class, "getResultStream");
 
 		@Override
-		protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) {
+		protected @Nullable Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) {
 
 			if (!SurroundingTransactionDetectorMethodInterceptor.INSTANCE.isSurroundingTransactionActive()) {
 				throw new InvalidDataAccessApiUsageException(NO_SURROUNDING_TRANSACTION);
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryFactory.java
deleted file mode 100644
index 82babfb9e4..0000000000
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryFactory.java
+++ /dev/null
@@ -1,67 +0,0 @@
-/*
- * Copyright 2013-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.springframework.data.jpa.repository.query;
-
-import jakarta.persistence.EntityManager;
-
-import org.springframework.data.jpa.repository.QueryRewriter;
-import org.springframework.data.repository.query.QueryCreationException;
-import org.springframework.data.repository.query.RepositoryQuery;
-import org.springframework.data.repository.query.ValueExpressionDelegate;
-import org.springframework.lang.Nullable;
-
-/**
- * Factory to create the appropriate {@link RepositoryQuery} for a {@link JpaQueryMethod}.
- *
- * @author Thomas Darimont
- * @author Mark Paluch
- */
-enum JpaQueryFactory {
-
-	INSTANCE;
-
-	/**
-	 * Creates a {@link RepositoryQuery} from the given {@link String} query.
-	 */
-	AbstractJpaQuery fromMethodWithQueryString(JpaQueryMethod method, EntityManager em, String queryString,
-			@Nullable String countQueryString, QueryRewriter queryRewriter,
-			ValueExpressionDelegate valueExpressionDelegate) {
-
-		if (method.isScrollQuery()) {
-			throw QueryCreationException.create(method, "Scroll queries are not supported using String-based queries");
-		}
-
-		return method.isNativeQuery()
-				? new NativeJpaQuery(method, em, queryString, countQueryString, queryRewriter, valueExpressionDelegate)
-				: new SimpleJpaQuery(method, em, queryString, countQueryString, queryRewriter, valueExpressionDelegate);
-	}
-
-	/**
-	 * Creates a {@link StoredProcedureJpaQuery} from the given {@link JpaQueryMethod} query.
-	 *
-	 * @param method must not be {@literal null}.
-	 * @param em must not be {@literal null}.
-	 * @return
-	 */
-	public StoredProcedureJpaQuery fromProcedureAnnotation(JpaQueryMethod method, EntityManager em) {
-
-		if (method.isScrollQuery()) {
-			throw QueryCreationException.create(method, "Scroll queries are not supported using stored procedures");
-		}
-
-		return new StoredProcedureJpaQuery(method, em);
-	}
-}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategy.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategy.java
index 6d25af839a..37f2e27d2a 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategy.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategy.java
@@ -21,21 +21,17 @@
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
+import org.jspecify.annotations.Nullable;
 
-import org.springframework.core.env.StandardEnvironment;
 import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.jpa.repository.QueryRewriter;
 import org.springframework.data.projection.ProjectionFactory;
 import org.springframework.data.repository.core.NamedQueries;
 import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.query.QueryCreationException;
 import org.springframework.data.repository.query.QueryLookupStrategy;
 import org.springframework.data.repository.query.QueryLookupStrategy.Key;
 import org.springframework.data.repository.query.QueryMethod;
-import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
-import org.springframework.data.repository.query.QueryMethodValueEvaluationContextAccessor;
 import org.springframework.data.repository.query.RepositoryQuery;
-import org.springframework.data.repository.query.ValueExpressionDelegate;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 
@@ -73,33 +69,31 @@ private abstract static class AbstractQueryLookupStrategy implements QueryLookup
 
 		private final EntityManager em;
 		private final JpaQueryMethodFactory queryMethodFactory;
-		private final QueryRewriterProvider queryRewriterProvider;
+		private final JpaQueryConfiguration configuration;
 
 		/**
 		 * Creates a new {@link AbstractQueryLookupStrategy}.
 		 *
 		 * @param em must not be {@literal null}.
 		 * @param queryMethodFactory must not be {@literal null}.
+		 * @param configuration must not be {@literal null}.
 		 */
 		public AbstractQueryLookupStrategy(EntityManager em, JpaQueryMethodFactory queryMethodFactory,
-				QueryRewriterProvider queryRewriterProvider) {
-
-			Assert.notNull(em, "EntityManager must not be null");
-			Assert.notNull(queryMethodFactory, "JpaQueryMethodFactory must not be null");
+				JpaQueryConfiguration configuration) {
 
 			this.em = em;
 			this.queryMethodFactory = queryMethodFactory;
-			this.queryRewriterProvider = queryRewriterProvider;
+			this.configuration = configuration;
 		}
 
 		@Override
 		public final RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory,
 				NamedQueries namedQueries) {
 			JpaQueryMethod queryMethod = queryMethodFactory.build(method, metadata, factory);
-			return resolveQuery(queryMethod, queryRewriterProvider.getQueryRewriter(queryMethod), em, namedQueries);
+			return resolveQuery(queryMethod, configuration, em, namedQueries);
 		}
 
-		protected abstract RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter queryRewriter,
+		protected abstract RepositoryQuery resolveQuery(JpaQueryMethod method, JpaQueryConfiguration configuration,
 				EntityManager em, NamedQueries namedQueries);
 
 	}
@@ -112,20 +106,16 @@ protected abstract RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewr
 	 */
 	private static class CreateQueryLookupStrategy extends AbstractQueryLookupStrategy {
 
-		private final EscapeCharacter escape;
-
 		public CreateQueryLookupStrategy(EntityManager em, JpaQueryMethodFactory queryMethodFactory,
-				QueryRewriterProvider queryRewriterProvider, EscapeCharacter escape) {
-
-			super(em, queryMethodFactory, queryRewriterProvider);
+				JpaQueryConfiguration configuration) {
 
-			this.escape = escape;
+			super(em, queryMethodFactory, configuration);
 		}
 
 		@Override
-		protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter queryRewriter, EntityManager em,
+		protected RepositoryQuery resolveQuery(JpaQueryMethod method, JpaQueryConfiguration configuration, EntityManager em,
 				NamedQueries namedQueries) {
-			return new PartTreeJpaQuery(method, em, escape);
+			return new PartTreeJpaQuery(method, em, configuration.getEscapeCharacter());
 		}
 	}
 
@@ -137,57 +127,62 @@ protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter quer
 	 * @author Thomas Darimont
 	 * @author Jens Schauder
 	 */
-	private static class DeclaredQueryLookupStrategy extends AbstractQueryLookupStrategy {
-
-		private final ValueExpressionDelegate valueExpressionDelegate;
+	static class DeclaredQueryLookupStrategy extends AbstractQueryLookupStrategy {
 
 		/**
 		 * Creates a new {@link DeclaredQueryLookupStrategy}.
 		 *
 		 * @param em must not be {@literal null}.
 		 * @param queryMethodFactory must not be {@literal null}.
-		 * @param evaluationContextProvider must not be {@literal null}.
+		 * @param configuration must not be {@literal null}.
 		 */
 		public DeclaredQueryLookupStrategy(EntityManager em, JpaQueryMethodFactory queryMethodFactory,
-				ValueExpressionDelegate delegate, QueryRewriterProvider queryRewriterProvider) {
-
-			super(em, queryMethodFactory, queryRewriterProvider);
+				JpaQueryConfiguration configuration) {
 
-			this.valueExpressionDelegate = delegate;
+			super(em, queryMethodFactory, configuration);
 		}
 
 		@Override
-		protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter queryRewriter, EntityManager em,
+		protected RepositoryQuery resolveQuery(JpaQueryMethod method, JpaQueryConfiguration configuration, EntityManager em,
 				NamedQueries namedQueries) {
 
 			if (method.isProcedureQuery()) {
-				return JpaQueryFactory.INSTANCE.fromProcedureAnnotation(method, em);
+				return createProcedureQuery(method, em);
 			}
 
-			if (StringUtils.hasText(method.getAnnotatedQuery())) {
+			if (method.hasAnnotatedQuery()) {
 
 				if (method.hasAnnotatedQueryName()) {
 					LOG.warn(String.format(
 							"Query method %s is annotated with both, a query and a query name; Using the declared query", method));
 				}
 
-				return JpaQueryFactory.INSTANCE.fromMethodWithQueryString(method, em, method.getRequiredAnnotatedQuery(),
-						getCountQuery(method, namedQueries, em), queryRewriter, valueExpressionDelegate);
+				return createStringQuery(method, em, method.getRequiredDeclaredQuery(),
+						getCountQuery(method, namedQueries, em), configuration);
 			}
 
 			String name = method.getNamedQueryName();
+
 			if (namedQueries.hasQuery(name)) {
-				return JpaQueryFactory.INSTANCE.fromMethodWithQueryString(method, em, namedQueries.getQuery(name),
-						getCountQuery(method, namedQueries, em), queryRewriter, valueExpressionDelegate);
+				return createStringQuery(method, em, method.getDeclaredQuery(namedQueries.getQuery(name)),
+						getCountQuery(method, namedQueries, em),
+						configuration);
 			}
 
-			RepositoryQuery query = NamedQuery.lookupFrom(method, em, queryRewriter);
+			RepositoryQuery query = NamedQuery.lookupFrom(method, em, configuration);
 
 			return query != null ? query : NO_QUERY;
 		}
 
-		@Nullable
-		private String getCountQuery(JpaQueryMethod method, NamedQueries namedQueries, EntityManager em) {
+		private @Nullable DeclaredQuery getCountQuery(JpaQueryMethod method, NamedQueries namedQueries, EntityManager em) {
+
+			String query = doGetCountQuery(method, namedQueries, em);
+
+			return StringUtils.hasText(query) ? method.getDeclaredQuery(query) : null;
+		}
+
+		private static @Nullable String doGetCountQuery(JpaQueryMethod method, NamedQueries namedQueries,
+				EntityManager em) {
 
 			if (StringUtils.hasText(method.getCountQuery())) {
 				return method.getCountQuery();
@@ -211,6 +206,44 @@ private String getCountQuery(JpaQueryMethod method, NamedQueries namedQueries, E
 
 			return null;
 		}
+
+		/**
+		 * Creates a {@link RepositoryQuery} from the given {@link String} query.
+		 *
+		 * @param method must not be {@literal null}.
+		 * @param em must not be {@literal null}.
+		 * @param query must not be {@literal null}.
+		 * @param countQuery can be {@literal null} if not defined.
+		 * @param configuration must not be {@literal null}.
+		 * @return
+		 */
+		static AbstractJpaQuery createStringQuery(JpaQueryMethod method, EntityManager em, DeclaredQuery query,
+				@Nullable DeclaredQuery countQuery, JpaQueryConfiguration configuration) {
+
+			if (method.isScrollQuery()) {
+				throw QueryCreationException.create(method, "Scroll queries are not supported using String-based queries");
+			}
+
+			return method.isNativeQuery() ? new NativeJpaQuery(method, em, query, countQuery, configuration)
+					: new SimpleJpaQuery(method, em, query, countQuery, configuration);
+		}
+
+		/**
+		 * Creates a {@link StoredProcedureJpaQuery} from the given {@link JpaQueryMethod} query.
+		 *
+		 * @param method must not be {@literal null}.
+		 * @param em must not be {@literal null}.
+		 * @return
+		 */
+		static StoredProcedureJpaQuery createProcedureQuery(JpaQueryMethod method, EntityManager em) {
+
+			if (method.isScrollQuery()) {
+				throw QueryCreationException.create(method, "Scroll queries are not supported using stored procedures");
+			}
+
+			return new StoredProcedureJpaQuery(method, em);
+		}
+
 	}
 
 	/**
@@ -233,31 +266,29 @@ private static class CreateIfNotFoundQueryLookupStrategy extends AbstractQueryLo
 		 * @param queryMethodFactory must not be {@literal null}.
 		 * @param createStrategy must not be {@literal null}.
 		 * @param lookupStrategy must not be {@literal null}.
+		 * @param configuration must not be {@literal null}.
 		 */
 		public CreateIfNotFoundQueryLookupStrategy(EntityManager em, JpaQueryMethodFactory queryMethodFactory,
 				CreateQueryLookupStrategy createStrategy, DeclaredQueryLookupStrategy lookupStrategy,
-				QueryRewriterProvider queryRewriterProvider) {
-
-			super(em, queryMethodFactory, queryRewriterProvider);
+				JpaQueryConfiguration configuration) {
 
-			Assert.notNull(createStrategy, "CreateQueryLookupStrategy must not be null");
-			Assert.notNull(lookupStrategy, "DeclaredQueryLookupStrategy must not be null");
+			super(em, queryMethodFactory, configuration);
 
 			this.createStrategy = createStrategy;
 			this.lookupStrategy = lookupStrategy;
 		}
 
 		@Override
-		protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter queryRewriter, EntityManager em,
+		protected RepositoryQuery resolveQuery(JpaQueryMethod method, JpaQueryConfiguration configuration, EntityManager em,
 				NamedQueries namedQueries) {
 
-			RepositoryQuery lookupQuery = lookupStrategy.resolveQuery(method, queryRewriter, em, namedQueries);
+			RepositoryQuery lookupQuery = lookupStrategy.resolveQuery(method, configuration, em, namedQueries);
 
 			if (lookupQuery != NO_QUERY) {
 				return lookupQuery;
 			}
 
-			return createStrategy.resolveQuery(method, queryRewriter, em, namedQueries);
+			return createStrategy.resolveQuery(method, configuration, em, namedQueries);
 		}
 	}
 
@@ -267,47 +298,20 @@ protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter quer
 	 * @param em must not be {@literal null}.
 	 * @param queryMethodFactory must not be {@literal null}.
 	 * @param key may be {@literal null}.
-	 * @param evaluationContextProvider must not be {@literal null}.
-	 * @param escape must not be {@literal null}.
-	 * @deprecated since 3.4, use
-	 *             {@link #create(EntityManager, JpaQueryMethodFactory, Key, ValueExpressionDelegate, QueryRewriterProvider, EscapeCharacter)}
-	 *             instead.
-	 */
-	@Deprecated(since = "3.4")
-	public static QueryLookupStrategy create(EntityManager em, JpaQueryMethodFactory queryMethodFactory,
-			@Nullable Key key, QueryMethodEvaluationContextProvider evaluationContextProvider,
-			QueryRewriterProvider queryRewriterProvider, EscapeCharacter escape) {
-		return create(em, queryMethodFactory, key,
-				new ValueExpressionDelegate(new QueryMethodValueEvaluationContextAccessor(new StandardEnvironment(),
-						evaluationContextProvider.getEvaluationContextProvider()), ValueExpressionDelegate.create()),
-				queryRewriterProvider, escape);
-	}
-
-	/**
-	 * Creates a {@link QueryLookupStrategy} for the given {@link EntityManager} and {@link Key}.
-	 *
-	 * @param em must not be {@literal null}.
-	 * @param queryMethodFactory must not be {@literal null}.
-	 * @param key may be {@literal null}.
-	 * @param delegate must not be {@literal null}.
-	 * @param queryRewriterProvider must not be {@literal null}.
-	 * @param escape must not be {@literal null}.
+	 * @param configuration must not be {@literal null}.
 	 */
 	public static QueryLookupStrategy create(EntityManager em, JpaQueryMethodFactory queryMethodFactory,
-			@Nullable Key key, ValueExpressionDelegate delegate, QueryRewriterProvider queryRewriterProvider,
-			EscapeCharacter escape) {
+			@Nullable Key key, JpaQueryConfiguration configuration) {
 
 		Assert.notNull(em, "EntityManager must not be null");
-		Assert.notNull(delegate, "ValueExpressionDelegate must not be null");
+		Assert.notNull(configuration, "JpaQueryConfiguration must not be null");
 
 		return switch (key != null ? key : Key.CREATE_IF_NOT_FOUND) {
-			case CREATE -> new CreateQueryLookupStrategy(em, queryMethodFactory, queryRewriterProvider, escape);
-			case USE_DECLARED_QUERY ->
-				new DeclaredQueryLookupStrategy(em, queryMethodFactory, delegate, queryRewriterProvider);
+			case CREATE -> new CreateQueryLookupStrategy(em, queryMethodFactory, configuration);
+			case USE_DECLARED_QUERY -> new DeclaredQueryLookupStrategy(em, queryMethodFactory, configuration);
 			case CREATE_IF_NOT_FOUND -> new CreateIfNotFoundQueryLookupStrategy(em, queryMethodFactory,
-					new CreateQueryLookupStrategy(em, queryMethodFactory, queryRewriterProvider, escape),
-					new DeclaredQueryLookupStrategy(em, queryMethodFactory, delegate, queryRewriterProvider),
-					queryRewriterProvider);
+					new CreateQueryLookupStrategy(em, queryMethodFactory, configuration),
+					new DeclaredQueryLookupStrategy(em, queryMethodFactory, configuration), configuration);
 			default -> throw new IllegalArgumentException(String.format("Unsupported query lookup strategy %s", key));
 		};
 	}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java
index 39202fcb77..10b985449d 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java
@@ -27,6 +27,8 @@
 import java.util.Set;
 import java.util.function.Function;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.core.annotation.AnnotatedElementUtils;
 import org.springframework.core.annotation.AnnotationUtils;
 import org.springframework.data.jpa.provider.QueryExtractor;
@@ -45,7 +47,6 @@
 import org.springframework.data.repository.util.QueryExecutionConverters;
 import org.springframework.data.util.Lazy;
 import org.springframework.data.util.TypeInformation;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 
@@ -164,7 +165,6 @@ private static Class<?> potentiallyUnwrapReturnTypeFor(RepositoryMetadata metada
 	}
 
 	@Override
-	@SuppressWarnings({ "rawtypes", "unchecked" })
 	public JpaEntityMetadata<?> getEntityInformation() {
 		return this.entityMetadata.get();
 	}
@@ -232,7 +232,7 @@ boolean applyHintsToCountQuery() {
 	 *
 	 * @return
 	 */
-	QueryExtractor getQueryExtractor() {
+	public QueryExtractor getQueryExtractor() {
 		return extractor;
 	}
 
@@ -295,14 +295,20 @@ public org.springframework.data.jpa.repository.query.Meta getQueryMetaAttributes
 		return metaAttributes;
 	}
 
+	/**
+	 * @return {@code true} if this method is annotated with {@code  @Query(value=…)}.
+	 */
+	boolean hasAnnotatedQuery() {
+		return StringUtils.hasText(getAnnotationValue("value", String.class));
+	}
+
 	/**
 	 * Returns the query string declared in a {@link Query} annotation or {@literal null} if neither the annotation found
 	 * nor the attribute was specified.
 	 *
 	 * @return
 	 */
-	@Nullable
-	public String getAnnotatedQuery() {
+	public @Nullable String getAnnotatedQuery() {
 
 		String query = getAnnotationValue("value", String.class);
 		return StringUtils.hasText(query) ? query : null;
@@ -334,19 +340,50 @@ public String getRequiredAnnotatedQuery() throws IllegalStateException {
 		throw new IllegalStateException(String.format("No annotated query found for query method %s", getName()));
 	}
 
+	/**
+	 * Returns the required {@link DeclaredQuery} from a {@link Query} annotation or throws {@link IllegalStateException}
+	 * if neither the annotation found nor the attribute was specified.
+	 *
+	 * @return
+	 * @throws IllegalStateException if no {@link Query} annotation is present or the query is empty.
+	 * @since 4.0
+	 */
+	public DeclaredQuery getRequiredDeclaredQuery() throws IllegalStateException {
+
+		String query = getAnnotatedQuery();
+
+		if (query != null) {
+			return getDeclaredQuery(query);
+		}
+
+		throw new IllegalStateException(String.format("No annotated query found for query method %s", getName()));
+	}
+
 	/**
 	 * Returns the countQuery string declared in a {@link Query} annotation or {@literal null} if neither the annotation
 	 * found nor the attribute was specified.
 	 *
 	 * @return
 	 */
-	@Nullable
-	public String getCountQuery() {
+	public @Nullable String getCountQuery() {
 
 		String countQuery = getAnnotationValue("countQuery", String.class);
 		return StringUtils.hasText(countQuery) ? countQuery : null;
 	}
 
+	/**
+	 * Returns the {@link DeclaredQuery declared count query} from a {@link Query} annotation or {@literal null} if
+	 * neither the annotation found nor the attribute was specified.
+	 *
+	 * @return
+	 * @since 4.0
+	 */
+	public @Nullable DeclaredQuery getDeclaredCountQuery() {
+
+		String countQuery = getAnnotationValue("countQuery", String.class);
+		return StringUtils.hasText(countQuery) ? getDeclaredQuery(countQuery) : null;
+	}
+
 	/**
 	 * Returns the count query projection string declared in a {@link Query} annotation or {@literal null} if neither the
 	 * annotation found nor the attribute was specified.
@@ -370,6 +407,17 @@ boolean isNativeQuery() {
 		return this.isNativeQuery.get();
 	}
 
+	/**
+	 * Utility method that returns a {@link DeclaredQuery} object for the given {@code queryString}.
+	 *
+	 * @param query the query string to wrap.
+	 * @return a {@link DeclaredQuery} object for the given {@code queryString}.
+	 * @since 4.0
+	 */
+	DeclaredQuery getDeclaredQuery(String query) {
+		return isNativeQuery() ? DeclaredQuery.nativeQuery(query) : DeclaredQuery.jpqlQuery(query);
+	}
+
 	@Override
 	public String getNamedQueryName() {
 
@@ -382,7 +430,7 @@ public String getNamedQueryName() {
 	 *
 	 * @return
 	 */
-	String getNamedCountQueryName() {
+	public String getNamedCountQueryName() {
 
 		String annotatedName = getAnnotationValue("countName", String.class);
 		return StringUtils.hasText(annotatedName) ? annotatedName : getNamedQueryName() + ".count";
@@ -418,7 +466,7 @@ private <T> T getAnnotationValue(String attribute, Class<T> type) {
 		return getMergedOrDefaultAnnotationValue(attribute, Query.class, type);
 	}
 
-	@SuppressWarnings({ "rawtypes", "unchecked" })
+	@SuppressWarnings({ "rawtypes", "unchecked", "NullAway" })
 	private <T> T getMergedOrDefaultAnnotationValue(String attribute, Class annotationType, Class<T> targetType) {
 
 		Annotation annotation = AnnotatedElementUtils.findMergedAnnotation(method, annotationType);
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryTransformerSupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryTransformerSupport.java
index 6cb8f11104..79a31e556f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryTransformerSupport.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryTransformerSupport.java
@@ -9,10 +9,11 @@
 import java.util.regex.Pattern;
 
 import org.springframework.dao.InvalidDataAccessApiUsageException;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Sort.NullHandling;
 import org.springframework.data.jpa.domain.JpaSort;
-import org.springframework.lang.Nullable;
 import org.springframework.util.ObjectUtils;
 
 /**
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaResultConverters.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaResultConverters.java
index 06382e5e9b..9ec1c5f1e5 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaResultConverters.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaResultConverters.java
@@ -22,9 +22,10 @@
 import java.sql.SQLException;
 
 import org.springframework.core.convert.converter.Converter;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.dao.CleanupFailureDataAccessException;
 import org.springframework.dao.DataRetrievalFailureException;
-import org.springframework.lang.Nullable;
 import org.springframework.util.StreamUtils;
 
 /**
@@ -50,9 +51,9 @@ enum BlobToByteArrayConverter implements Converter<Blob, byte[]> {
 
 		INSTANCE;
 
-		@Nullable
+
 		@Override
-		public byte[] convert(@Nullable Blob source) {
+		public byte @Nullable[] convert(@Nullable Blob source) {
 
 			if (source == null) {
 				return null;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlCountQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlCountQueryTransformer.java
index 89e4d54070..6318d8acfd 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlCountQueryTransformer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlCountQueryTransformer.java
@@ -17,9 +17,11 @@
 
 import static org.springframework.data.jpa.repository.query.QueryTokens.*;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
 import org.springframework.data.jpa.repository.query.QueryTransformers.CountSelectionTokenStream;
-import org.springframework.lang.Nullable;
+import org.springframework.util.StringUtils;
 
 /**
  * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed JPQL query into a
@@ -42,7 +44,7 @@ class JpqlCountQueryTransformer extends JpqlQueryRenderer {
 	}
 
 	@Override
-	public QueryRenderer.QueryRendererBuilder visitSelect_statement(JpqlParser.Select_statementContext ctx) {
+	public QueryTokenStream visitSelect_statement(JpqlParser.Select_statementContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
@@ -58,6 +60,9 @@ public QueryRenderer.QueryRendererBuilder visitSelect_statement(JpqlParser.Selec
 		if (ctx.having_clause() != null) {
 			builder.appendExpression(visit(ctx.having_clause()));
 		}
+		if (ctx.set_fuction() != null) {
+			builder.appendExpression(visit(ctx.set_fuction()));
+		}
 
 		return builder;
 	}
@@ -77,8 +82,10 @@ public QueryRendererBuilder visitSelect_clause(JpqlParser.Select_clauseContext c
 			if (usesDistinct) {
 				nested.append(QueryTokens.expression(ctx.DISTINCT()));
 				nested.append(getDistinctCountSelection(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA)));
-			} else {
+			} else if (StringUtils.hasText(primaryFromAlias)) {
 				nested.append(QueryTokens.token(primaryFromAlias));
+			} else {
+				throw new IllegalStateException("No primary alias present");
 			}
 		} else {
 			builder.append(QueryTokens.token(countProjection));
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java
new file mode 100644
index 0000000000..45c804e124
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java
@@ -0,0 +1,1449 @@
+/*
+ * 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.springframework.data.jpa.repository.query;
+
+import static org.springframework.data.jpa.repository.query.QueryTokens.*;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Supplier;
+
+import org.springframework.data.domain.Sort;
+
+import org.jspecify.annotations.Nullable;
+import org.springframework.data.mapping.PropertyPath;
+import org.springframework.data.util.Predicates;
+import org.springframework.lang.CheckReturnValue;
+import org.springframework.lang.Contract;
+import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.ObjectUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * A Domain-Specific Language to build JPQL queries using Java code.
+ *
+ * @author Mark Paluch
+ */
+@SuppressWarnings("JavadocDeclaration")
+public final class JpqlQueryBuilder {
+
+	private JpqlQueryBuilder() {}
+
+	/**
+	 * Create an {@link Entity} from the given {@link Class entity class}.
+	 *
+	 * @param from the entity type to select from.
+	 * @return
+	 */
+	public static Entity entity(Class<?> from) {
+		return new Entity(from.getName(), from.getSimpleName(),
+				getAlias(from.getSimpleName(), Predicates.isTrue(), () -> "r"));
+	}
+
+	/**
+	 * Create a {@link Join INNER JOIN}.
+	 *
+	 * @param origin the selection origin (a join or the entity itself) to select from.
+	 * @param path
+	 * @return
+	 */
+	public static Join innerJoin(Origin origin, String path) {
+		return new Join(origin, "INNER JOIN", path);
+	}
+
+	/**
+	 * Create a {@link Join LEFT JOIN}.
+	 *
+	 * @param origin the selection origin (a join or the entity itself) to select from.
+	 * @param path
+	 * @return
+	 */
+	public static Join leftJoin(Origin origin, String path) {
+		return new Join(origin, "LEFT JOIN", path);
+	}
+
+	/**
+	 * Start building a {@link Select} statement by selecting {@link Class from}. This is a short form for
+	 * {@code selectFrom(entity(from))}.
+	 *
+	 * @param from the entity type to select from.
+	 * @return
+	 */
+	public static SelectStep selectFrom(Class<?> from) {
+		return selectFrom(entity(from));
+	}
+
+	/**
+	 * Start building a {@link Select} statement by selecting {@link Entity from}.
+	 *
+	 * @param from the entity source to select from.
+	 * @return a new select builder.
+	 */
+	public static SelectStep selectFrom(Entity from) {
+
+		return new SelectStep() {
+
+			boolean distinct = false;
+
+			@Override
+			public SelectStep distinct() {
+
+				distinct = true;
+				return this;
+			}
+
+			@Override
+			public Select entity() {
+				return new Select(postProcess(new EntitySelection(from)), from);
+			}
+
+			@Override
+			public Select count() {
+				return new Select(new CountSelection(from, distinct), from);
+			}
+
+			@Override
+			public Select instantiate(String resultType, Collection<JpqlQueryBuilder.PathExpression> paths) {
+				return new Select(postProcess(new ConstructorExpression(resultType, new Multiselect(from, paths))), from);
+			}
+
+			@Override
+			public Select select(Collection<JpqlQueryBuilder.PathExpression> paths) {
+				return new Select(postProcess(new Multiselect(from, paths)), from);
+			}
+
+			Selection postProcess(Selection selection) {
+				return distinct ? new DistinctSelection(selection) : selection;
+			}
+		};
+	}
+
+	private static String getAlias(String from, java.util.function.Predicate<String> predicate,
+			Supplier<String> fallback) {
+
+		char c = from.toLowerCase(Locale.ROOT).charAt(0);
+		String string = Character.toString(c);
+		if (Character.isJavaIdentifierPart(c) && predicate.test(string)) {
+			return string;
+		}
+
+		return fallback.get();
+	}
+
+	/**
+	 * Invoke a {@literal function} with the given {@code arguments}.
+	 *
+	 * @param function function name.
+	 * @param arguments function arguments.
+	 * @return an expression representing a function call.
+	 */
+	public static Expression function(String function, Expression... arguments) {
+		return new FunctionExpression(function, Arrays.asList(arguments));
+	}
+
+	/**
+	 * Nest the given {@link Predicate}.
+	 *
+	 * @param predicate
+	 * @return
+	 */
+	public static Predicate nested(Predicate predicate) {
+		return new NestedPredicate(predicate);
+	}
+
+	/**
+	 * Create a qualified expression for a {@link PropertyPath}.
+	 *
+	 * @param source
+	 * @param path
+	 * @return
+	 */
+	public static Expression expression(Origin source, PropertyPath path) {
+		return new PathAndOrigin(path, source, false);
+	}
+
+	/**
+	 * Create a simple expression from a string as-is.
+	 *
+	 * @param expression
+	 * @return
+	 */
+	public static Expression expression(String expression) {
+
+		Assert.hasText(expression, "Expression must not be empty or null");
+
+		return new LiteralExpression(expression);
+	}
+
+	/**
+	 * Create a simple numeric literal.
+	 *
+	 * @param literal
+	 * @return
+	 */
+	public static Expression literal(Number literal) {
+		return new LiteralExpression(literal.toString());
+	}
+
+	/**
+	 * Create a simple literal from a string by quoting it.
+	 *
+	 * @param literal
+	 * @return
+	 */
+	public static Expression literal(String literal) {
+		return new StringLiteralExpression(literal);
+	}
+
+	/**
+	 * A parameter placeholder.
+	 *
+	 * @param parameter
+	 * @return
+	 */
+	public static Expression parameter(String parameter) {
+
+		Assert.hasText(parameter, "Parameter must not be empty or null");
+
+		return new ParameterExpression(new ParameterPlaceholder(parameter));
+	}
+
+	/**
+	 * A parameter placeholder.
+	 *
+	 * @param placeholder the placeholder to use.
+	 * @return
+	 */
+	public static Expression parameter(ParameterPlaceholder placeholder) {
+		return new ParameterExpression(placeholder);
+	}
+
+	/**
+	 * Create a new ordering expression.
+	 *
+	 * @param sortExpression
+	 * @param order
+	 * @return
+	 */
+	public static Expression orderBy(Expression sortExpression, Sort.Order order) {
+		return new OrderExpression(sortExpression, order);
+	}
+
+	/**
+	 * Start building a {@link Predicate WHERE predicate} by providing the right-hand side.
+	 *
+	 * @param source
+	 * @param path
+	 * @return
+	 */
+	public static WhereStep where(Origin source, PropertyPath path) {
+		return where(expression(source, path));
+	}
+
+	/**
+	 * Start building a {@link Predicate WHERE predicate} by providing the right-hand side.
+	 *
+	 * @param rhs
+	 * @return
+	 */
+	public static WhereStep where(Expression rhs) {
+
+		return new WhereStep() {
+			@Override
+			public Predicate between(Expression lower, Expression upper) {
+				return new BetweenPredicate(rhs, lower, upper);
+			}
+
+			@Override
+			public Predicate gt(Expression value) {
+				return new OperatorPredicate(rhs, ">", value);
+			}
+
+			@Override
+			public Predicate gte(Expression value) {
+				return new OperatorPredicate(rhs, ">=", value);
+			}
+
+			@Override
+			public Predicate lt(Expression value) {
+				return new OperatorPredicate(rhs, "<", value);
+			}
+
+			@Override
+			public Predicate lte(Expression value) {
+				return new OperatorPredicate(rhs, "<=", value);
+			}
+
+			@Override
+			public Predicate isNull() {
+				return new LhsPredicate(rhs, "IS NULL");
+			}
+
+			@Override
+			public Predicate isNotNull() {
+				return new LhsPredicate(rhs, "IS NOT NULL");
+			}
+
+			@Override
+			public Predicate isTrue() {
+				return new LhsPredicate(rhs, "= TRUE");
+			}
+
+			@Override
+			public Predicate isFalse() {
+				return new LhsPredicate(rhs, "= FALSE");
+			}
+
+			@Override
+			public Predicate isEmpty() {
+				return new LhsPredicate(rhs, "IS EMPTY");
+			}
+
+			@Override
+			public Predicate isNotEmpty() {
+				return new LhsPredicate(rhs, "IS NOT EMPTY");
+			}
+
+			@Override
+			public Predicate in(Expression value) {
+				return new InPredicate(rhs, "IN", value);
+			}
+
+			@Override
+			public Predicate notIn(Expression value) {
+				return new InPredicate(rhs, "NOT IN", value);
+			}
+
+			@Override
+			public Predicate memberOf(Expression value) {
+				return new MemberOfPredicate(rhs, "MEMBER OF", value);
+			}
+
+			@Override
+			public Predicate notMemberOf(Expression value) {
+				return new MemberOfPredicate(rhs, "NOT MEMBER OF", value);
+			}
+
+			@Override
+			public Predicate like(Expression value, String escape) {
+				return new LikePredicate(rhs, "LIKE", value, escape);
+			}
+
+			@Override
+			public Predicate notLike(Expression value, String escape) {
+				return new LikePredicate(rhs, "NOT LIKE", value, escape);
+			}
+
+			@Override
+			public Predicate eq(Expression value) {
+				return new OperatorPredicate(rhs, "=", value);
+			}
+
+			@Override
+			public Predicate neq(Expression value) {
+				return new OperatorPredicate(rhs, "!=", value);
+			}
+		};
+	}
+
+	public static @Nullable Predicate and(List<Predicate> intermediate) {
+
+		Predicate predicate = null;
+
+		for (Predicate other : intermediate) {
+
+			if (predicate == null) {
+				predicate = other;
+			} else {
+				predicate = predicate.and(other);
+			}
+		}
+
+		return predicate;
+	}
+
+	public static @Nullable Predicate or(List<Predicate> intermediate) {
+
+		Predicate predicate = null;
+
+		for (Predicate other : intermediate) {
+
+			if (predicate == null) {
+				predicate = other;
+			} else {
+				predicate = predicate.or(other);
+			}
+		}
+
+		return predicate;
+	}
+
+	/**
+	 * Fluent interface to build a {@link Select}.
+	 */
+	public interface SelectStep {
+
+		/**
+		 * Apply {@code DISTINCT}.
+		 */
+		@CheckReturnValue
+		SelectStep distinct();
+
+		/**
+		 * Select the entity.
+		 */
+		@CheckReturnValue
+		Select entity();
+
+		/**
+		 * Select the count.
+		 */
+		@CheckReturnValue
+		Select count();
+
+		/**
+		 * Provide a constructor expression to instantiate {@code resultType}. Operates on the underlying {@link Entity
+		 * FROM}.
+		 *
+		 * @param resultType
+		 * @param paths
+		 * @return
+		 */
+		@CheckReturnValue
+		default Select instantiate(Class<?> resultType, Collection<JpqlQueryBuilder.PathExpression> paths) {
+			return instantiate(resultType.getName(), paths);
+		}
+
+		/**
+		 * Provide a constructor expression to instantiate {@code resultType}.
+		 *
+		 * @param resultType
+		 * @param paths
+		 * @return
+		 */
+		@CheckReturnValue
+		Select instantiate(String resultType, Collection<JpqlQueryBuilder.PathExpression> paths);
+
+		/**
+		 * Specify a multi-select.
+		 *
+		 * @param paths
+		 * @return
+		 */
+		@CheckReturnValue
+		Select select(Collection<JpqlQueryBuilder.PathExpression> paths);
+
+		/**
+		 * Select a single attribute.
+		 *
+		 * @param path
+		 * @return
+		 */
+		@CheckReturnValue
+		default Select select(JpqlQueryBuilder.PathExpression path) {
+			return select(List.of(path));
+		}
+
+	}
+
+	interface Selection {
+		String render(RenderContext context);
+	}
+
+	/**
+	 * {@code DISTINCT} wrapper.
+	 *
+	 * @param selection
+	 */
+	record DistinctSelection(Selection selection) implements Selection {
+
+		@Override
+		public String render(RenderContext context) {
+			return "DISTINCT %s".formatted(selection.render(context));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	static PathAndOrigin path(Origin origin, String path) {
+
+		if (origin instanceof Entity entity) {
+
+			try {
+				PropertyPath from = PropertyPath.from(path, ClassUtils.forName(entity.entity, Entity.class.getClassLoader()));
+				return new PathAndOrigin(from, entity, false);
+			} catch (ClassNotFoundException e) {
+				throw new RuntimeException(e);
+			}
+		}
+		if (origin instanceof Join join) {
+
+			Origin parent = join.source;
+			List<String> segments = new ArrayList<>();
+			segments.add(join.path);
+			while (!(parent instanceof Entity)) {
+				if (parent instanceof Join pj) {
+					parent = pj.source;
+					segments.add(pj.path);
+				} else {
+					parent = null;
+				}
+			}
+
+			if (parent instanceof Entity) {
+				Collections.reverse(segments);
+				segments.add(path);
+				PathAndOrigin path1 = path(parent, StringUtils.collectionToDelimitedString(segments, "."));
+				return new PathAndOrigin(path1.path().getLeafProperty(), origin, false);
+			}
+		}
+		throw new IllegalStateException(" oh no ");
+
+	}
+
+	/**
+	 * Entity selection.
+	 *
+	 * @param source
+	 */
+	record EntitySelection(Entity source) implements Selection {
+
+		@Override
+		public String render(RenderContext context) {
+			return context.getAlias(source);
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	/**
+	 * {@code COUNT(…)} selection.
+	 *
+	 * @param source
+	 * @param distinct
+	 */
+	record CountSelection(Entity source, boolean distinct) implements Selection {
+
+		@Override
+		public String render(RenderContext context) {
+			return "COUNT(%s%s)".formatted(distinct ? "DISTINCT " : "", context.getAlias(source));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	/**
+	 * Expression selection.
+	 *
+	 * @param resultType
+	 * @param multiselect
+	 */
+	record ConstructorExpression(String resultType, Multiselect multiselect) implements Selection {
+
+		@Override
+		public String render(RenderContext context) {
+
+			return "new %s(%s)".formatted(resultType, multiselect.render(new ConstructorContext(context)));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	/**
+	 * Multi-select selecting one or many property paths.
+	 *
+	 * @param source
+	 * @param paths
+	 */
+	record Multiselect(Origin source, Collection<JpqlQueryBuilder.PathExpression> paths) implements Selection {
+
+		@Override
+		public String render(RenderContext context) {
+
+			StringBuilder builder = new StringBuilder();
+
+			for (PathExpression path : paths) {
+
+				if (!builder.isEmpty()) {
+					builder.append(", ");
+				}
+
+				builder.append(path.render(context));
+				if (!context.isConstructorContext()) {
+					builder.append(" ").append(path.getPropertyPath().getSegment());
+				}
+			}
+
+			return builder.toString();
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	/**
+	 * {@code WHERE} predicate.
+	 */
+	public interface Predicate {
+
+		/**
+		 * Render the predicate given {@link RenderContext}.
+		 *
+		 * @param context
+		 * @return
+		 */
+		String render(RenderContext context);
+
+		/**
+		 * {@code OR}-concatenate this predicate with {@code other}.
+		 *
+		 * @param other
+		 * @return a composed predicate combining this and {@code other} using the OR operator.
+		 */
+		@Contract("_ -> new")
+		@CheckReturnValue
+		default Predicate or(Predicate other) {
+			return new OrPredicate(this, other);
+		}
+
+		/**
+		 * {@code AND}-concatenate this predicate with {@code other}.
+		 *
+		 * @param other
+		 * @return a composed predicate combining this and {@code other} using the AND operator.
+		 */
+		@Contract("_ -> new")
+		@CheckReturnValue
+		default Predicate and(Predicate other) { // don't like the structuring of this and the nest() thing
+			return new AndPredicate(this, other);
+		}
+
+		/**
+		 * Wrap this predicate with parenthesis {@code (…)} to nest it without affecting AND/OR concatenation precedence.
+		 *
+		 * @return a nested variant of this predicate.
+		 */
+		@Contract("-> new")
+		@CheckReturnValue
+		default Predicate nest() {
+			return new NestedPredicate(this);
+		}
+	}
+
+	/**
+	 * Interface specifying an expression that can be rendered to {@code String}.
+	 */
+	public interface Expression {
+
+		/**
+		 * Render the expression given {@link RenderContext}.
+		 *
+		 * @param context
+		 * @return
+		 */
+		String render(RenderContext context);
+	}
+
+	/**
+	 * Extension to {@link Expression} that contains a {@link PropertyPath}. Typically used to represent a selection
+	 * expression or an expression used within sorting or {@code WHERE} clauses.
+	 */
+	public interface PathExpression extends Expression {
+
+		/**
+		 * @return the associated {@link PropertyPath}.
+		 */
+		PropertyPath getPropertyPath();
+	}
+
+	/**
+	 * {@code SELECT} statement.
+	 */
+	public static class Select extends AbstractJpqlQuery {
+
+		private final Selection selection;
+
+		private final Entity entity;
+
+		private final Map<String, Join> joins = new LinkedHashMap<>();
+
+		private final List<Expression> orderBy = new ArrayList<>();
+
+		private Select(Selection selection, Entity entity) {
+			this.selection = selection;
+			this.entity = entity;
+		}
+
+		/**
+		 * Append a join to this select.
+		 *
+		 * @param join
+		 * @return
+		 */
+		@Contract("_ -> this")
+		public Select join(Join join) {
+
+			if (join.source() instanceof Join parent) {
+				join(parent);
+			}
+
+			this.joins.put(join.joinType() + "_" + join.getName() + "_" + join.path(), join);
+			return this;
+		}
+
+		/**
+		 * Append an order-by expression to this select.
+		 *
+		 * @param orderBy
+		 * @return
+		 */
+		@Contract("_ -> this")
+		public Select orderBy(Expression orderBy) {
+			this.orderBy.add(orderBy);
+			return this;
+		}
+
+		@Override
+		String render() {
+
+			Map<Origin, String> aliases = new LinkedHashMap<>();
+			aliases.put(entity, entity.alias);
+
+			RenderContext renderContext = new RenderContext(aliases);
+
+			StringBuilder where = new StringBuilder();
+			StringBuilder orderby = new StringBuilder();
+			StringBuilder result = new StringBuilder(
+					"SELECT %s FROM %s %s".formatted(selection.render(renderContext), entity.getEntity(), entity.getAlias()));
+
+			if (getWhere() != null) {
+				where.append(" WHERE ").append(getWhere().render(renderContext));
+			}
+
+			if (!orderBy.isEmpty()) {
+
+				StringBuilder builder = new StringBuilder();
+
+				for (Expression order : orderBy) {
+					if (!builder.isEmpty()) {
+						builder.append(", ");
+					}
+
+					builder.append(order.render(renderContext));
+				}
+
+				orderby.append(" ORDER BY ").append(builder);
+			}
+
+			aliases.keySet().forEach(key -> {
+
+				if (key instanceof Join js) {
+					join(js);
+				}
+			});
+
+			for (Join join : joins.values()) {
+				result.append(" ").append(join.joinType()).append(" ").append(renderContext.getAlias(join.source())).append(".")
+						.append(join.path()).append(" ").append(renderContext.getAlias(join));
+			}
+
+			result.append(where).append(orderby);
+
+			return result.toString();
+		}
+	}
+
+	/**
+	 * Abstract base class for JPQL queries.
+	 */
+	public static abstract class AbstractJpqlQuery {
+
+		private @Nullable Predicate where;
+
+		public AbstractJpqlQuery where(Predicate predicate) {
+			this.where = predicate;
+			return this;
+		}
+
+		public @Nullable Predicate getWhere() {
+			return where;
+		}
+
+		abstract String render();
+
+		@Override
+		public String toString() {
+			return render();
+		}
+	}
+
+	record OrderExpression(Expression sortExpression, Sort.Order order) implements Expression {
+
+		@Override
+		public String render(RenderContext context) {
+
+			StringBuilder builder = new StringBuilder();
+
+			builder.append(sortExpression.render(context));
+			builder.append(" ");
+
+			builder.append(order.isDescending() ? TOKEN_DESC : TOKEN_ASC);
+
+			if (order.getNullHandling() == Sort.NullHandling.NULLS_FIRST) {
+				builder.append(" NULLS FIRST");
+			} else if (order.getNullHandling() == Sort.NullHandling.NULLS_LAST) {
+				builder.append(" NULLS LAST");
+			}
+
+			return builder.toString();
+		}
+	}
+
+	/**
+	 * Context used during rendering.
+	 */
+	public static class RenderContext {
+
+		public static final RenderContext EMPTY = new RenderContext(Collections.emptyMap()) {
+
+			@Override
+			public String getAlias(Origin source) {
+				return "";
+			}
+		};
+
+		private final Map<Origin, String> aliases;
+		private int counter;
+
+		RenderContext(Map<Origin, String> aliases) {
+			this.aliases = aliases;
+		}
+
+		/**
+		 * Obtain an alias for {@link Origin}. Unknown selection origins are associated with the enclosing statement if they
+		 * are used for the first time.
+		 *
+		 * @param source
+		 * @return
+		 */
+		public String getAlias(Origin source) {
+
+			return aliases.computeIfAbsent(source, it -> JpqlQueryBuilder.getAlias(source.getName(), s -> !aliases.containsValue(s), () -> "join_" + (counter++)));
+		}
+
+		/**
+		 * Prefix {@code fragment} with the alias for {@link Origin}. Unknown selection origins are associated with the
+		 * enclosing statement if they are used for the first time.
+		 *
+		 * @param source
+		 * @return
+		 */
+		public String prefixWithAlias(Origin source, String fragment) {
+
+			String alias = getAlias(source);
+			return ObjectUtils.isEmpty(source) ? fragment : alias + "." + fragment;
+		}
+
+		public boolean isConstructorContext() {
+			return false;
+		}
+	}
+
+	static class ConstructorContext extends RenderContext {
+
+		ConstructorContext(RenderContext rootContext) {
+			super(rootContext.aliases);
+		}
+
+		@Override
+		public boolean isConstructorContext() {
+			return true;
+		}
+	}
+
+	/**
+	 * An origin that is used to select data from. selection origins are used with paths to define where a path is
+	 * anchored.
+	 */
+	public interface Origin {
+
+		/**
+		 * Returns the simple name of the origin (e.g. {@link Class#getSimpleName()} or JOIN path name).
+		 *
+		 * @return the simple name of the origin (e.g. {@link Class#getSimpleName()})
+		 */
+		String getName();
+	}
+
+	/**
+	 * An origin that is used to select data from. selection origins are used with paths to define where a path is
+	 * anchored.
+	 */
+	public interface Bindable {
+
+		boolean isRoot();
+	}
+
+	/**
+	 * The root entity.
+	 */
+	public static final class Entity implements Origin {
+
+		private final String entity;
+		private final String simpleName;
+		private final String alias;
+
+		/**
+		 * @param entity fully-qualified entity name.
+		 * @param simpleName simple class name.
+		 * @param alias alias to use.
+		 */
+		Entity(String entity, String simpleName, String alias) {
+			this.entity = entity;
+			this.simpleName = simpleName;
+			this.alias = alias;
+		}
+
+		public String getEntity() {
+			return entity;
+		}
+
+		@Override
+		public String getName() {
+			return simpleName;
+		}
+
+		public String getAlias() {
+			return alias;
+		}
+
+		@Override
+		public boolean equals(Object obj) {
+			if (obj == this) {
+				return true;
+			}
+			if (obj == null || obj.getClass() != this.getClass()) {
+				return false;
+			}
+			var that = (Entity) obj;
+			return Objects.equals(this.entity, that.entity) && Objects.equals(this.simpleName, that.simpleName)
+					&& Objects.equals(this.alias, that.alias);
+		}
+
+		@Override
+		public int hashCode() {
+			return Objects.hash(entity, simpleName, alias);
+		}
+
+		@Override
+		public String toString() {
+			return "Entity[" + "entity=" + entity + ", " + "simpleName=" + simpleName + ", " + "alias=" + alias + ']';
+		}
+
+	}
+
+	/**
+	 * A joined entity or element collection.
+	 */
+	public static final class Join implements Origin, Expression {
+
+		private final Origin source;
+		private final String joinType;
+		private final String path;
+
+		/**
+		 * @param source
+		 * @param joinType
+		 * @param path
+		 */
+		Join(Origin source, String joinType, String path) {
+			this.source = source;
+			this.joinType = joinType;
+			this.path = path;
+		}
+
+		@Override
+		public String getName() {
+			return path;
+		}
+
+		@Override
+		public String render(RenderContext context) {
+			return "%s %s %s".formatted(joinType, context.getAlias(source), path);
+		}
+
+		public Origin source() {
+			return source;
+		}
+
+		public String joinType() {
+			return joinType;
+		}
+
+		public String path() {
+			return path;
+		}
+
+		@Override
+		public boolean equals(Object obj) {
+			if (obj == this) {
+				return true;
+			}
+			if (obj == null || obj.getClass() != this.getClass()) {
+				return false;
+			}
+			var that = (Join) obj;
+			return Objects.equals(this.source, that.source) && Objects.equals(this.joinType, that.joinType)
+					&& Objects.equals(this.path, that.path);
+		}
+
+		@Override
+		public int hashCode() {
+			return Objects.hash(source, joinType, path);
+		}
+
+		@Override
+		public String toString() {
+			return "Join[" + "source=" + source + ", " + "joinType=" + joinType + ", " + "path=" + path + ']';
+		}
+
+	}
+
+	/**
+	 * Fluent interface to build a {@link Predicate}.
+	 */
+	public interface WhereStep {
+
+		/**
+		 * Create a {@code BETWEEN … AND …} predicate.
+		 *
+		 * @param lower lower boundary.
+		 * @param upper upper boundary.
+		 * @return
+		 */
+		Predicate between(Expression lower, Expression upper);
+
+		/**
+		 * Create a greater {@code > …} predicate.
+		 *
+		 * @param value the comparison value.
+		 * @return
+		 */
+		Predicate gt(Expression value);
+
+		/**
+		 * Create a greater-or-equals {@code >= …} predicate.
+		 *
+		 * @param value the comparison value.
+		 * @return
+		 */
+		Predicate gte(Expression value);
+
+		/**
+		 * Create a less {@code < …} predicate.
+		 *
+		 * @param value the comparison value.
+		 * @return
+		 */
+		Predicate lt(Expression value);
+
+		/**
+		 * Create a less-or-equals {@code <= …} predicate.
+		 *
+		 * @param value the comparison value.
+		 * @return
+		 */
+		Predicate lte(Expression value);
+
+		/**
+		 * Create a {@code IS NULL} predicate.
+		 *
+		 * @return
+		 */
+		Predicate isNull();
+
+		/**
+		 * Create a {@code IS NOT NULL} predicate.
+		 *
+		 * @return
+		 */
+		Predicate isNotNull();
+
+		/**
+		 * Create a {@code IS TRUE} predicate.
+		 *
+		 * @return
+		 */
+		Predicate isTrue();
+
+		/**
+		 * Create a {@code IS FALSE} predicate.
+		 *
+		 * @return
+		 */
+		Predicate isFalse();
+
+		/**
+		 * Create a {@code IS EMPTY} predicate.
+		 *
+		 * @return
+		 */
+		Predicate isEmpty();
+
+		/**
+		 * Create a {@code IS NOT EMPTY} predicate.
+		 *
+		 * @return
+		 */
+		Predicate isNotEmpty();
+
+		/**
+		 * Create a {@code IN} predicate.
+		 *
+		 * @param value
+		 * @return
+		 */
+		Predicate in(Expression value);
+
+		/**
+		 * Create a {@code NOT IN} predicate.
+		 *
+		 * @param value
+		 * @return
+		 */
+		Predicate notIn(Expression value);
+
+		/**
+		 * Create a {@code MEMBER OF &lt;collection&gt;} predicate.
+		 *
+		 * @param value
+		 * @return
+		 */
+		Predicate memberOf(Expression value);
+
+		/**
+		 * Create a {@code NOT MEMBER OF &lt;collection&gt;} predicate.
+		 *
+		 * @param value
+		 * @return
+		 */
+		Predicate notMemberOf(Expression value);
+
+		default Predicate like(String value, String escape) {
+			return like(expression(value), escape);
+		}
+
+		/**
+		 * Create a {@code LIKE … ESCAPE} predicate.
+		 *
+		 * @param value
+		 * @return
+		 */
+		Predicate like(Expression value, String escape);
+
+		/**
+		 * Create a {@code NOT LIKE … ESCAPE} predicate.
+		 *
+		 * @param value
+		 * @return
+		 */
+		Predicate notLike(Expression value, String escape);
+
+		/**
+		 * Create a {@code =} (equals) predicate.
+		 *
+		 * @param value
+		 * @return
+		 */
+		Predicate eq(Expression value);
+
+		/**
+		 * Create a {@code &lt;&gt;} (not equals) predicate.
+		 *
+		 * @param value
+		 * @return
+		 */
+		Predicate neq(Expression value);
+	}
+
+	record LiteralExpression(String expression) implements Expression {
+
+		@Override
+		public String render(RenderContext context) {
+			return expression;
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record StringLiteralExpression(String literal) implements Expression {
+
+		@Override
+		public String render(RenderContext context) {
+			return "'%s'".formatted(literal.replaceAll("'", "''"));
+		}
+
+		public String raw() {
+			return literal;
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record ParameterExpression(ParameterPlaceholder parameter) implements Expression {
+
+		@Override
+		public String render(RenderContext context) {
+			return parameter.placeholder;
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record FunctionExpression(String function, List<Expression> arguments) implements Expression {
+
+		@Override
+		public String render(RenderContext context) {
+
+			StringBuilder builder = new StringBuilder();
+
+			for (Expression argument : arguments) {
+
+				if (!builder.isEmpty()) {
+					builder.append(", ");
+				}
+
+				builder.append(argument.render(context));
+			}
+
+			return "%s(%s)".formatted(function, builder);
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record OperatorPredicate(Expression path, String operator, Expression predicate) implements Predicate {
+
+		@Override
+		public String render(RenderContext context) {
+			return "%s %s %s".formatted(path.render(context), operator, predicate.render(context));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record MemberOfPredicate(Expression path, String operator, Expression predicate) implements Predicate {
+
+		@Override
+		public String render(RenderContext context) {
+			return "%s %s %s".formatted(predicate.render(context), operator, path.render(context));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record LhsPredicate(Expression path, String predicate) implements Predicate {
+
+		@Override
+		public String render(RenderContext context) {
+			return "%s %s".formatted(path.render(context), predicate);
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record BetweenPredicate(Expression path, Expression lower, Expression upper) implements Predicate {
+
+		@Override
+		public String render(RenderContext context) {
+			return "%s BETWEEN %s AND %s".formatted(path.render(context), lower.render(context), upper.render(context));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record LikePredicate(Expression left, String operator, Expression right, String escape) implements Predicate {
+
+		@Override
+		public String render(RenderContext context) {
+			return "%s %s %s ESCAPE '%s'".formatted(left.render(context), operator, right.render(context), escape);
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record InPredicate(Expression path, String operator, Expression predicate) implements Predicate {
+
+		@Override
+		public String render(RenderContext context) {
+
+			// TODO: should we rather wrap it with nested or check if its a nested predicate before we call render
+			return "%s %s (%s)".formatted(path.render(context), operator, predicate.render(context));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record AndPredicate(Predicate left, Predicate right) implements Predicate {
+
+		@Override
+		public String render(RenderContext context) {
+			return "%s AND %s".formatted(left.render(context), right.render(context));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record OrPredicate(Predicate left, Predicate right) implements Predicate {
+
+		@Override
+		public String render(RenderContext context) {
+			return "%s OR %s".formatted(left.render(context), right.render(context));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	record NestedPredicate(Predicate delegate) implements Predicate {
+
+		@Override
+		public String render(RenderContext context) {
+			return "(%s)".formatted(delegate.render(context));
+		}
+
+		@Override
+		public String toString() {
+			return render(RenderContext.EMPTY);
+		}
+	}
+
+	/**
+	 * Value object capturing a property path and its origin.
+	 *
+	 * @param path
+	 * @param origin
+	 * @param onTheJoin whether the path should target the join itself instead of matching {@link PropertyPath}.
+	 */
+	record PathAndOrigin(PropertyPath path, Origin origin, boolean onTheJoin) implements PathExpression {
+
+		@Override
+		public PropertyPath getPropertyPath() {
+			return path;
+		}
+
+		@Override
+		public String render(RenderContext context) {
+
+			if (path().hasNext() || !onTheJoin()) {
+				return context.prefixWithAlias(origin(), path().toDotPath());
+			} else {
+				return context.getAlias(origin());
+			}
+		}
+	}
+
+	/**
+	 * Value object capturing parameter placeholder.
+	 *
+	 * @param placeholder
+	 */
+	public record ParameterPlaceholder(String placeholder) {
+
+		public ParameterPlaceholder {
+			Assert.hasText(placeholder, "Placeholder must not be null nor empty");
+		}
+
+		/**
+		 * Factory method to create a parameter placeholder using a parameter {@code index}.
+		 *
+		 * @param index the parameter index.
+		 * @return an indexed parameter placeholder.
+		 */
+		public static ParameterPlaceholder indexed(int index) {
+			return new ParameterPlaceholder("?%s".formatted(index));
+		}
+
+		/**
+		 * Factory method to create a parameter placeholder using a parameter {@code name}.
+		 *
+		 * @param name the parameter name.
+		 * @return a named parameter placeholder.
+		 */
+		public static ParameterPlaceholder named(String name) {
+
+			Assert.hasText(name, "Placeholder name must not be empty");
+			return new ParameterPlaceholder(":%s".formatted(name));
+		}
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaJpa21UtilsTests.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java
similarity index 67%
rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaJpa21UtilsTests.java
rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java
index 4c5cac42e1..039392d571 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaJpa21UtilsTests.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2017-2025 the original author or authors.
+ * 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.
@@ -15,12 +15,20 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import org.springframework.test.context.ContextConfiguration;
+import java.util.List;
+
+import org.springframework.data.domain.Sort;
 
 /**
- * @author Christoph Strobl
+ * @author Mark Paluch
  */
-@ContextConfiguration("classpath:openjpa.xml")
-class OpenJpaJpa21UtilsTests extends Jpa21UtilsTests {
+interface JpqlQueryCreator {
+
+	boolean useTupleQuery();
+
+	String createQuery(Sort sort);
+
+	List<ParameterBinding> getBindings();
 
+	ParameterBinder getBinder();
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryIntrospector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryIntrospector.java
index 48f6fef46b..43f6f7fd1f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryIntrospector.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryIntrospector.java
@@ -21,7 +21,7 @@
 import java.util.Collections;
 import java.util.List;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * {@link ParsedQueryIntrospector} for JPQL queries.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryRenderer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryRenderer.java
index fad4187df7..762bfab02a 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryRenderer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryRenderer.java
@@ -24,7 +24,10 @@
 
 import org.springframework.data.jpa.repository.query.JpqlParser.NullsPrecedenceContext;
 import org.springframework.data.jpa.repository.query.JpqlParser.Reserved_wordContext;
+import org.springframework.data.jpa.repository.query.JpqlParser.Set_fuctionContext;
+import org.springframework.data.jpa.repository.query.JpqlParser.Type_literalContext;
 import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
+import org.springframework.util.ObjectUtils;
 
 /**
  * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that renders a JPQL query without making any changes.
@@ -79,6 +82,10 @@ public QueryTokenStream visitSelect_statement(JpqlParser.Select_statementContext
 			builder.appendExpression(visit(ctx.orderby_clause()));
 		}
 
+		if (ctx.set_fuction() != null) {
+			builder.appendExpression(visit(ctx.set_fuction()));
+		}
+
 		return builder;
 	}
 
@@ -146,15 +153,10 @@ public QueryTokenStream visitIdentification_variable_declaration(
 			JpqlParser.Identification_variable_declarationContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
-		builder.appendExpression(visit(ctx.range_variable_declaration()));
 
-		ctx.join().forEach(joinContext -> {
-			builder.append(visit(joinContext));
-		});
-
-		ctx.fetch_join().forEach(fetchJoinContext -> {
-			builder.append(visit(fetchJoinContext));
-		});
+		builder.append(visit(ctx.range_variable_declaration()));
+		builder.appendExpression(QueryTokenStream.concat(ctx.join(), this::visit, TOKEN_SPACE));
+		builder.appendExpression(QueryTokenStream.concat(ctx.fetch_join(), this::visit, TOKEN_SPACE));
 
 		return builder;
 	}
@@ -180,14 +182,19 @@ public QueryTokenStream visitJoin(JpqlParser.JoinContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(visit(ctx.join_spec()));
-		builder.append(visit(ctx.join_association_path_expression()));
+		builder.appendExpression(visit(ctx.join_spec()));
+		builder.appendExpression(visit(ctx.join_association_path_expression()));
+
 		if (ctx.AS() != null) {
 			builder.append(QueryTokens.expression(ctx.AS()));
 		}
-		builder.append(visit(ctx.identification_variable()));
+
+		if (ctx.identification_variable() != null) {
+			builder.appendExpression(visit(ctx.identification_variable()));
+		}
+
 		if (ctx.join_condition() != null) {
-			builder.append(visit(ctx.join_condition()));
+			builder.appendExpression(visit(ctx.join_condition()));
 		}
 
 		return builder;
@@ -198,9 +205,19 @@ public QueryTokenStream visitFetch_join(JpqlParser.Fetch_joinContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(visit(ctx.join_spec()));
+		builder.appendExpression(visit(ctx.join_spec()));
 		builder.append(QueryTokens.expression(ctx.FETCH()));
-		builder.append(visit(ctx.join_association_path_expression()));
+		builder.appendExpression(visit(ctx.join_association_path_expression()));
+
+		if (ctx.AS() != null) {
+			builder.append(QueryTokens.expression(ctx.AS()));
+		}
+		if (ctx.identification_variable() != null) {
+			builder.appendExpression(visit(ctx.identification_variable()));
+		}
+		if (ctx.join_condition() != null) {
+			builder.appendExpression(visit(ctx.join_condition()));
+		}
 
 		return builder;
 	}
@@ -251,23 +268,25 @@ public QueryTokenStream visitJoin_association_path_expression(
 				builder.appendExpression(visit(ctx.join_single_valued_path_expression()));
 			}
 		} else {
+			QueryRendererBuilder nested = QueryRenderer.builder();
+
 			if (ctx.join_collection_valued_path_expression() != null) {
 
-				builder.append(QueryTokens.token(ctx.TREAT()));
-				builder.append(TOKEN_OPEN_PAREN);
-				builder.appendInline(visit(ctx.join_collection_valued_path_expression()));
-				builder.append(QueryTokens.expression(ctx.AS()));
-				builder.appendInline(visit(ctx.subtype()));
-				builder.append(TOKEN_CLOSE_PAREN);
+				nested.appendExpression(visit(ctx.join_collection_valued_path_expression()));
+				nested.append(QueryTokens.expression(ctx.AS()));
+				nested.appendExpression(visit(ctx.subtype()));
+
 			} else if (ctx.join_single_valued_path_expression() != null) {
 
-				builder.append(QueryTokens.token(ctx.TREAT()));
-				builder.append(TOKEN_OPEN_PAREN);
-				builder.appendInline(visit(ctx.join_single_valued_path_expression()));
-				builder.append(QueryTokens.expression(ctx.AS()));
-				builder.appendInline(visit(ctx.subtype()));
-				builder.append(TOKEN_CLOSE_PAREN);
+				nested.appendExpression(visit(ctx.join_single_valued_path_expression()));
+				nested.append(QueryTokens.expression(ctx.AS()));
+				nested.appendExpression(visit(ctx.subtype()));
 			}
+
+			builder.append(QueryTokens.token(ctx.TREAT()));
+			builder.append(TOKEN_OPEN_PAREN);
+			builder.appendInline(nested);
+			builder.append(TOKEN_CLOSE_PAREN);
 		}
 
 		return builder;
@@ -427,12 +446,15 @@ public QueryTokenStream visitSimple_subpath(JpqlParser.Simple_subpathContext ctx
 	public QueryTokenStream visitTreated_subpath(JpqlParser.Treated_subpathContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
+		QueryRendererBuilder nested = QueryRenderer.builder();
+
+		nested.appendExpression(visit(ctx.general_subpath()));
+		nested.append(QueryTokens.expression(ctx.AS()));
+		nested.appendExpression(visit(ctx.subtype()));
 
 		builder.append(QueryTokens.token(ctx.TREAT()));
 		builder.append(TOKEN_OPEN_PAREN);
-		builder.appendInline(visit(ctx.general_subpath()));
-		builder.append(QueryTokens.expression(ctx.AS()));
-		builder.appendInline(visit(ctx.subtype()));
+		builder.appendInline(nested);
 		builder.append(TOKEN_CLOSE_PAREN);
 
 		return builder;
@@ -544,7 +566,7 @@ public QueryTokenStream visitNew_value(JpqlParser.New_valueContext ctx) {
 		} else if (ctx.simple_entity_expression() != null) {
 			return visit(ctx.simple_entity_expression());
 		} else if (ctx.NULL() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.NULL()));
+			return QueryTokenStream.ofToken(ctx.NULL());
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -791,8 +813,7 @@ public QueryTokenStream visitOrderby_item(JpqlParser.Orderby_itemContext ctx) {
 
 		if (ctx.ASC() != null) {
 			builder.append(QueryTokens.expression(ctx.ASC()));
-		}
-		if (ctx.DESC() != null) {
+		} else if (ctx.DESC() != null) {
 			builder.append(QueryTokens.expression(ctx.DESC()));
 		}
 
@@ -808,12 +829,44 @@ public QueryTokenStream visitNullsPrecedence(NullsPrecedenceContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(TOKEN_NULLS);
+		builder.append(QueryTokens.expression(ctx.NULLS()));
 
 		if (ctx.FIRST() != null) {
-			builder.append(TOKEN_FIRST);
+			builder.append(QueryTokens.expression(ctx.FIRST()));
 		} else if (ctx.LAST() != null) {
-			builder.append(TOKEN_LAST);
+			builder.append(QueryTokens.expression(ctx.LAST()));
+		}
+
+		return builder;
+	}
+
+	@Override
+	public QueryTokenStream visitSet_fuction(Set_fuctionContext ctx) {
+
+		QueryRendererBuilder builder = QueryRenderer.builder();
+
+		if (ctx.setOperator() != null) {
+			builder.append(visit(ctx.setOperator()));
+		}
+
+		builder.appendExpression(visit(ctx.select_statement()));
+
+		return builder;
+	}
+
+	@Override
+	public QueryTokenStream visitSetOperator(JpqlParser.SetOperatorContext ctx) {
+
+		QueryRendererBuilder builder = QueryRenderer.builder();
+
+		if (ctx.INTERSECT() != null) {
+			builder.append(QueryTokens.expression(ctx.INTERSECT()));
+		} else if (ctx.UNION() != null) {
+			builder.append(QueryTokens.expression(ctx.UNION()));
+		} else if (ctx.EXCEPT() != null) {
+			builder.append(QueryTokens.expression(ctx.EXCEPT()));
+		} else if (ctx.ALL() != null) {
+			builder.append(QueryTokens.expression(ctx.ALL()));
 		}
 
 		return builder;
@@ -931,6 +984,8 @@ public QueryTokenStream visitScalar_expression(JpqlParser.Scalar_expressionConte
 			return visit(ctx.case_expression());
 		} else if (ctx.entity_type_expression() != null) {
 			return visit(ctx.entity_type_expression());
+		} else if (ctx.cast_function() != null) {
+			return (visit(ctx.cast_function()));
 		}
 
 		return QueryTokenStream.empty();
@@ -1164,9 +1219,11 @@ public QueryTokenStream visitNull_comparison_expression(JpqlParser.Null_comparis
 		}
 
 		builder.append(QueryTokens.expression(ctx.IS()));
+
 		if (ctx.NOT() != null) {
 			builder.append(QueryTokens.expression(ctx.NOT()));
 		}
+
 		builder.append(QueryTokens.expression(ctx.NULL()));
 
 		return builder;
@@ -1349,7 +1406,7 @@ public QueryTokenStream visitComparison_expression(JpqlParser.Comparison_express
 
 	@Override
 	public QueryTokenStream visitComparison_operator(JpqlParser.Comparison_operatorContext ctx) {
-		return QueryRenderer.from(QueryTokens.token(ctx.op));
+		return QueryTokenStream.ofToken(ctx.op);
 	}
 
 	@Override
@@ -1420,6 +1477,8 @@ public QueryTokenStream visitArithmetic_primary(JpqlParser.Arithmetic_primaryCon
 			builder.append(visit(ctx.aggregate_expression()));
 		} else if (ctx.case_expression() != null) {
 			builder.append(visit(ctx.case_expression()));
+		} else if (ctx.cast_function() != null) {
+			builder.append(visit(ctx.cast_function()));
 		} else if (ctx.function_invocation() != null) {
 			builder.append(visit(ctx.function_invocation()));
 		} else if (ctx.subquery() != null) {
@@ -1456,6 +1515,11 @@ public QueryTokenStream visitString_expression(JpqlParser.String_expressionConte
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.appendInline(visit(ctx.subquery()));
 			builder.append(TOKEN_CLOSE_PAREN);
+		} else if (!ObjectUtils.isEmpty(ctx.string_expression())) {
+
+			builder.appendInline(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_DOUBLE_PIPE);
+			builder.appendExpression(visit(ctx.string_expression(1)));
 		}
 
 		return builder;
@@ -1699,6 +1763,8 @@ public QueryTokenStream visitFunctions_returning_numerics(JpqlParser.Functions_r
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.appendInline(visit(ctx.identification_variable()));
 			builder.append(TOKEN_CLOSE_PAREN);
+		} else if (ctx.extract_datetime_field() != null) {
+			builder.append(visit(ctx.extract_datetime_field()));
 		}
 
 		return builder;
@@ -1726,6 +1792,8 @@ public QueryTokenStream visitFunctions_returning_datetime(JpqlParser.Functions_r
 			} else if (ctx.DATETIME() != null) {
 				builder.append(QueryTokens.expression(ctx.DATETIME()));
 			}
+		} else if (ctx.extract_datetime_part() != null) {
+			builder.append(visit(ctx.extract_datetime_part()));
 		}
 
 		return builder;
@@ -1747,22 +1815,26 @@ public QueryTokenStream visitFunctions_returning_strings(JpqlParser.Functions_re
 			builder.append(QueryTokens.token(ctx.SUBSTRING()));
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.append(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_COMMA);
 			builder.appendInline(QueryTokenStream.concat(ctx.arithmetic_expression(), this::visit, TOKEN_COMMA));
 			builder.append(TOKEN_CLOSE_PAREN);
 		} else if (ctx.TRIM() != null) {
 
 			builder.append(QueryTokens.token(ctx.TRIM()));
 			builder.append(TOKEN_OPEN_PAREN);
+
+			QueryRendererBuilder nested = QueryRenderer.builder();
 			if (ctx.trim_specification() != null) {
-				builder.appendExpression(visit(ctx.trim_specification()));
+				nested.appendExpression(visit(ctx.trim_specification()));
 			}
 			if (ctx.trim_character() != null) {
-				builder.appendExpression(visit(ctx.trim_character()));
+				nested.appendExpression(visit(ctx.trim_character()));
 			}
 			if (ctx.FROM() != null) {
-				builder.append(QueryTokens.expression(ctx.FROM()));
+				nested.append(QueryTokens.expression(ctx.FROM()));
 			}
-			builder.append(visit(ctx.string_expression(0)));
+			nested.append(visit(ctx.string_expression(0)));
+			builder.appendInline(nested);
 			builder.append(TOKEN_CLOSE_PAREN);
 		} else if (ctx.LOWER() != null) {
 
@@ -1776,6 +1848,29 @@ public QueryTokenStream visitFunctions_returning_strings(JpqlParser.Functions_re
 			builder.append(TOKEN_OPEN_PAREN);
 			builder.append(visit(ctx.string_expression(0)));
 			builder.append(TOKEN_CLOSE_PAREN);
+		} else if (ctx.LEFT() != null) {
+			builder.append(QueryTokens.token(ctx.LEFT()));
+			builder.append(TOKEN_OPEN_PAREN);
+			builder.appendInline(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_COMMA);
+			builder.appendInline(visit(ctx.arithmetic_expression(0)));
+			builder.append(TOKEN_CLOSE_PAREN);
+		} else if (ctx.RIGHT() != null) {
+			builder.append(QueryTokens.token(ctx.RIGHT()));
+			builder.append(TOKEN_OPEN_PAREN);
+			builder.appendInline(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_COMMA);
+			builder.appendInline(visit(ctx.arithmetic_expression(0)));
+			builder.append(TOKEN_CLOSE_PAREN);
+		} else if (ctx.REPLACE() != null) {
+			builder.append(QueryTokens.token(ctx.REPLACE()));
+			builder.append(TOKEN_OPEN_PAREN);
+			builder.appendInline(visit(ctx.string_expression(0)));
+			builder.append(TOKEN_COMMA);
+			builder.appendInline(visit(ctx.string_expression(1)));
+			builder.append(TOKEN_COMMA);
+			builder.appendInline(visit(ctx.string_expression(2)));
+			builder.append(TOKEN_CLOSE_PAREN);
 		}
 
 		return builder;
@@ -1785,14 +1880,36 @@ public QueryTokenStream visitFunctions_returning_strings(JpqlParser.Functions_re
 	public QueryTokenStream visitTrim_specification(JpqlParser.Trim_specificationContext ctx) {
 
 		if (ctx.LEADING() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.LEADING()));
+			return QueryTokenStream.ofToken(ctx.LEADING());
 		} else if (ctx.TRAILING() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.TRAILING()));
+			return QueryTokenStream.ofToken(ctx.TRAILING());
 		} else {
-			return QueryRenderer.from(QueryTokens.expression(ctx.BOTH()));
+			return QueryTokenStream.ofToken(ctx.BOTH());
 		}
 	}
 
+	@Override
+	public QueryTokenStream visitCast_function(JpqlParser.Cast_functionContext ctx) {
+
+		QueryRendererBuilder builder = QueryRenderer.builder();
+
+		builder.append(QueryTokens.token(ctx.CAST()));
+		builder.append(TOKEN_OPEN_PAREN);
+		builder.appendInline(visit(ctx.single_valued_path_expression()));
+		builder.append(TOKEN_SPACE);
+		builder.appendInline(QueryTokenStream.concat(ctx.identification_variable(), this::visit, TOKEN_SPACE));
+
+		if (!ObjectUtils.isEmpty(ctx.numeric_literal())) {
+
+			builder.append(TOKEN_OPEN_PAREN);
+			builder.appendInline(QueryTokenStream.concat(ctx.numeric_literal(), this::visit, TOKEN_COMMA));
+			builder.append(TOKEN_CLOSE_PAREN);
+		}
+		builder.append(TOKEN_CLOSE_PAREN);
+
+		return builder;
+	}
+
 	@Override
 	public QueryTokenStream visitFunction_invocation(JpqlParser.Function_invocationContext ctx) {
 
@@ -1814,12 +1931,15 @@ public QueryTokenStream visitFunction_invocation(JpqlParser.Function_invocationC
 	public QueryTokenStream visitExtract_datetime_field(JpqlParser.Extract_datetime_fieldContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
+		QueryRendererBuilder nested = QueryRenderer.builder();
+
+		nested.appendExpression(visit(ctx.datetime_field()));
+		nested.append(QueryTokens.expression(ctx.FROM()));
+		nested.appendExpression(visit(ctx.datetime_expression()));
 
-		builder.append(QueryTokens.expression(ctx.EXTRACT()));
+		builder.append(QueryTokens.token(ctx.EXTRACT()));
 		builder.append(TOKEN_OPEN_PAREN);
-		builder.appendExpression(visit(ctx.datetime_field()));
-		builder.append(QueryTokens.expression(ctx.FROM()));
-		builder.appendInline(visit(ctx.datetime_expression()));
+		builder.appendInline(nested);
 		builder.append(TOKEN_CLOSE_PAREN);
 
 		return builder;
@@ -1834,12 +1954,15 @@ public QueryTokenStream visitDatetime_field(JpqlParser.Datetime_fieldContext ctx
 	public QueryTokenStream visitExtract_datetime_part(JpqlParser.Extract_datetime_partContext ctx) {
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
+		QueryRendererBuilder nested = QueryRenderer.builder();
+
+		nested.appendExpression(visit(ctx.datetime_part()));
+		nested.append(QueryTokens.expression(ctx.FROM()));
+		nested.appendExpression(visit(ctx.datetime_expression()));
 
-		builder.append(QueryTokens.expression(ctx.EXTRACT()));
+		builder.append(QueryTokens.token(ctx.EXTRACT()));
 		builder.append(TOKEN_OPEN_PAREN);
-		builder.appendExpression(visit(ctx.datetime_part()));
-		builder.append(QueryTokens.expression(ctx.FROM()));
-		builder.append(visit(ctx.datetime_expression()));
+		builder.appendInline(nested);
 		builder.append(TOKEN_CLOSE_PAREN);
 
 		return builder;
@@ -1878,6 +2001,14 @@ public QueryTokenStream visitCase_expression(JpqlParser.Case_expressionContext c
 		}
 	}
 
+	@Override
+	public QueryRendererBuilder visitType_literal(Type_literalContext ctx) {
+
+		QueryRendererBuilder builder = QueryRenderer.builder();
+		ctx.children.forEach(it -> builder.append(QueryTokens.expression(it.getText())));
+		return builder;
+	}
+
 	@Override
 	public QueryTokenStream visitGeneral_case_expression(JpqlParser.General_case_expressionContext ctx) {
 
@@ -1963,7 +2094,7 @@ public QueryTokenStream visitNullif_expression(JpqlParser.Nullif_expressionConte
 
 		QueryRendererBuilder builder = QueryRenderer.builder();
 
-		builder.append(QueryTokens.expression(ctx.NULLIF()));
+		builder.append(QueryTokens.token(ctx.NULLIF()));
 		builder.append(TOKEN_OPEN_PAREN);
 		builder.appendInline(visit(ctx.scalar_expression(0)));
 		builder.append(TOKEN_COMMA);
@@ -1977,7 +2108,7 @@ public QueryTokenStream visitNullif_expression(JpqlParser.Nullif_expressionConte
 	public QueryTokenStream visitTrim_character(JpqlParser.Trim_characterContext ctx) {
 
 		if (ctx.CHARACTER() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.CHARACTER()));
+			return QueryTokenStream.ofToken(ctx.CHARACTER());
 		} else if (ctx.character_valued_input_parameter() != null) {
 			return visit(ctx.character_valued_input_parameter());
 		} else {
@@ -1989,9 +2120,11 @@ public QueryTokenStream visitTrim_character(JpqlParser.Trim_characterContext ctx
 	public QueryTokenStream visitIdentification_variable(JpqlParser.Identification_variableContext ctx) {
 
 		if (ctx.IDENTIFICATION_VARIABLE() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.IDENTIFICATION_VARIABLE()));
+			return QueryTokenStream.ofToken(ctx.IDENTIFICATION_VARIABLE());
+		} else if (ctx.type_literal() != null) {
+			return visit(ctx.type_literal());
 		} else if (ctx.f != null) {
-			return QueryRenderer.from(QueryTokens.token(ctx.f));
+			return QueryTokenStream.ofToken(ctx.f);
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -2006,15 +2139,15 @@ public QueryTokenStream visitConstructor_name(JpqlParser.Constructor_nameContext
 	public QueryTokenStream visitLiteral(JpqlParser.LiteralContext ctx) {
 
 		if (ctx.STRINGLITERAL() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.STRINGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.STRINGLITERAL());
 		} else if (ctx.JAVASTRINGLITERAL() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.JAVASTRINGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.JAVASTRINGLITERAL());
 		} else if (ctx.INTLITERAL() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.INTLITERAL()));
+			return QueryTokenStream.ofToken(ctx.INTLITERAL());
 		} else if (ctx.FLOATLITERAL() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.FLOATLITERAL()));
+			return QueryTokenStream.ofToken(ctx.FLOATLITERAL());
 		} else if (ctx.LONGLITERAL() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.LONGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.LONGLITERAL());
 		} else if (ctx.boolean_literal() != null) {
 			return visit(ctx.boolean_literal());
 		} else if (ctx.entity_type_literal() != null) {
@@ -2049,7 +2182,18 @@ public QueryTokenStream visitPattern_value(JpqlParser.Pattern_valueContext ctx)
 
 	@Override
 	public QueryTokenStream visitDate_time_timestamp_literal(JpqlParser.Date_time_timestamp_literalContext ctx) {
-		return QueryRenderer.from(QueryTokens.expression(ctx.STRINGLITERAL()));
+
+		if (ctx.STRINGLITERAL() != null) {
+			return QueryTokenStream.ofToken(ctx.STRINGLITERAL());
+		} else if (ctx.DATELITERAL() != null) {
+			return QueryTokenStream.ofToken(ctx.DATELITERAL());
+		} else if (ctx.TIMELITERAL() != null) {
+			return QueryTokenStream.ofToken(ctx.TIMELITERAL());
+		} else if (ctx.TIMESTAMPLITERAL() != null) {
+			return QueryTokenStream.ofToken(ctx.TIMESTAMPLITERAL());
+		} else {
+			return QueryRenderer.builder();
+		}
 	}
 
 	@Override
@@ -2059,18 +2203,18 @@ public QueryTokenStream visitEntity_type_literal(JpqlParser.Entity_type_literalC
 
 	@Override
 	public QueryTokenStream visitEscape_character(JpqlParser.Escape_characterContext ctx) {
-		return QueryRenderer.from(QueryTokens.expression(ctx.CHARACTER()));
+		return QueryTokenStream.ofToken(ctx.CHARACTER());
 	}
 
 	@Override
 	public QueryTokenStream visitNumeric_literal(JpqlParser.Numeric_literalContext ctx) {
 
 		if (ctx.INTLITERAL() != null) {
-			return QueryRenderer.from(QueryTokens.token(ctx.INTLITERAL()));
+			return QueryTokenStream.ofToken(ctx.INTLITERAL());
 		} else if (ctx.FLOATLITERAL() != null) {
-			return QueryRenderer.from(QueryTokens.token(ctx.FLOATLITERAL()));
+			return QueryTokenStream.ofToken(ctx.FLOATLITERAL());
 		} else if (ctx.LONGLITERAL() != null) {
-			return QueryRenderer.from(QueryTokens.token(ctx.LONGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.LONGLITERAL());
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -2080,9 +2224,9 @@ public QueryTokenStream visitNumeric_literal(JpqlParser.Numeric_literalContext c
 	public QueryTokenStream visitBoolean_literal(JpqlParser.Boolean_literalContext ctx) {
 
 		if (ctx.TRUE() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.TRUE()));
+			return QueryTokenStream.ofToken(ctx.TRUE());
 		} else if (ctx.FALSE() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.FALSE()));
+			return QueryTokenStream.ofToken(ctx.FALSE());
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -2097,9 +2241,9 @@ public QueryTokenStream visitEnum_literal(JpqlParser.Enum_literalContext ctx) {
 	public QueryTokenStream visitString_literal(JpqlParser.String_literalContext ctx) {
 
 		if (ctx.CHARACTER() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.CHARACTER()));
+			return QueryTokenStream.ofToken(ctx.CHARACTER());
 		} else if (ctx.STRINGLITERAL() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.STRINGLITERAL()));
+			return QueryTokenStream.ofToken(ctx.STRINGLITERAL());
 		} else {
 			return QueryTokenStream.empty();
 		}
@@ -2153,7 +2297,7 @@ public QueryTokenStream visitCollection_value_field(JpqlParser.Collection_value_
 
 	@Override
 	public QueryTokenStream visitEntity_name(JpqlParser.Entity_nameContext ctx) {
-		return QueryTokenStream.concat(ctx.reserved_word(), this::visitReserved_word, TOKEN_DOT);
+		return QueryTokenStream.concat(ctx.reserved_word(), this::visit, TOKEN_DOT);
 	}
 
 	@Override
@@ -2188,7 +2332,7 @@ public QueryTokenStream visitCharacter_valued_input_parameter(
 			JpqlParser.Character_valued_input_parameterContext ctx) {
 
 		if (ctx.CHARACTER() != null) {
-			return QueryRenderer.from(QueryTokens.expression(ctx.CHARACTER()));
+			return QueryTokenStream.ofToken(ctx.CHARACTER());
 		} else if (ctx.input_parameter() != null) {
 			return visit(ctx.input_parameter());
 		} else {
@@ -2199,9 +2343,9 @@ public QueryTokenStream visitCharacter_valued_input_parameter(
 	@Override
 	public QueryTokenStream visitReserved_word(Reserved_wordContext ctx) {
 		if (ctx.IDENTIFICATION_VARIABLE() != null) {
-			return QueryRenderer.from(QueryTokens.token(ctx.IDENTIFICATION_VARIABLE()));
+			return QueryTokenStream.ofToken(ctx.IDENTIFICATION_VARIABLE());
 		} else if (ctx.f != null) {
-			return QueryRenderer.from(QueryTokens.token(ctx.f));
+			return QueryTokenStream.ofToken(ctx.f);
 		} else {
 			return QueryTokenStream.empty();
 		}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java
index 41d0661d2c..654fb7df88 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java
@@ -20,9 +20,10 @@
 import java.util.List;
 
 import org.springframework.data.domain.Sort;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
 import org.springframework.data.repository.query.ReturnedType;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
@@ -71,7 +72,11 @@ public QueryTokenStream visitSelect_statement(JpqlParser.Select_statementContext
 			builder.appendExpression(visit(ctx.having_clause()));
 		}
 
-		doVisitOrderBy(builder, ctx);
+		if(ctx.set_fuction() != null) {
+			builder.appendExpression(visit(ctx.set_fuction()));
+		} else {
+			doVisitOrderBy(builder, ctx);
+		}
 
 		return builder;
 	}
@@ -129,7 +134,7 @@ public QueryTokenStream visitSelect_item(JpqlParser.Select_itemContext ctx) {
 		QueryTokenStream tokens = super.visitSelect_item(ctx);
 
 		if (ctx.result_variable() != null && !tokens.isEmpty()) {
-			transformerSupport.registerAlias(tokens.getLast());
+			transformerSupport.registerAlias(tokens.getRequiredLast());
 		}
 
 		return tokens;
@@ -141,9 +146,10 @@ public QueryTokenStream visitJoin(JpqlParser.JoinContext ctx) {
 		QueryTokenStream tokens = super.visitJoin(ctx);
 
 		if (!tokens.isEmpty()) {
-			transformerSupport.registerAlias(tokens.getLast());
+			transformerSupport.registerAlias(tokens.getRequiredLast());
 		}
 
 		return tokens;
 	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java
new file mode 100644
index 0000000000..298b095915
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlUtils.java
@@ -0,0 +1,162 @@
+/*
+ * 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.springframework.data.jpa.repository.query;
+
+import jakarta.persistence.criteria.From;
+import jakarta.persistence.metamodel.Attribute;
+import jakarta.persistence.metamodel.Attribute.PersistentAttributeType;
+import jakarta.persistence.metamodel.Bindable;
+import jakarta.persistence.metamodel.ManagedType;
+import jakarta.persistence.metamodel.Metamodel;
+import jakarta.persistence.metamodel.PluralAttribute;
+
+import java.util.Objects;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.data.mapping.PropertyPath;
+import org.springframework.util.StringUtils;
+
+/**
+ * Utilities to create JPQL expressions, derived from {@link QueryUtils}.
+ *
+ * @author Mark Paluch
+ */
+class JpqlUtils {
+
+	static JpqlQueryBuilder.PathExpression toExpressionRecursively(@Nullable Metamodel metamodel,
+			JpqlQueryBuilder.Origin source, Bindable<?> from, PropertyPath property) {
+		return toExpressionRecursively(metamodel, source, from, property, false);
+	}
+
+	static JpqlQueryBuilder.PathExpression toExpressionRecursively(@Nullable Metamodel metamodel,
+			JpqlQueryBuilder.Origin source, Bindable<?> from, PropertyPath property, boolean isForSelection) {
+		return toExpressionRecursively(metamodel, source, from, property, isForSelection, false);
+	}
+
+	/**
+	 * Creates an expression with proper inner and left joins by recursively navigating the path
+	 *
+	 * @param from the {@link From}
+	 * @param property the property path
+	 * @param isForSelection is the property navigated for the selection or ordering part of the query?
+	 * @param hasRequiredOuterJoin has a parent already required an outer join?
+	 * @return the expression
+	 */
+	static JpqlQueryBuilder.PathExpression toExpressionRecursively(@Nullable Metamodel metamodel,
+			JpqlQueryBuilder.Origin source, Bindable<?> from, PropertyPath property, boolean isForSelection,
+			boolean hasRequiredOuterJoin) {
+
+		String segment = property.getSegment();
+
+		boolean isLeafProperty = !property.hasNext();
+		boolean requiresOuterJoin = requiresOuterJoin(metamodel, from, property, isForSelection, hasRequiredOuterJoin);
+
+		// if it does not require an outer join and is a leaf, simply get the segment
+		if (!requiresOuterJoin && isLeafProperty) {
+			return new JpqlQueryBuilder.PathAndOrigin(property, source, false);
+		}
+
+		// get or create the join
+		JpqlQueryBuilder.Join joinSource = requiresOuterJoin ? JpqlQueryBuilder.leftJoin(source, segment)
+				: JpqlQueryBuilder.innerJoin(source, segment);
+
+		// if it's a leaf, return the join
+		if (isLeafProperty) {
+			return new JpqlQueryBuilder.PathAndOrigin(property, joinSource, true);
+		}
+
+		PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null");
+
+		ManagedType<?> managedTypeForModel = QueryUtils.getManagedTypeForModel(from);
+		Attribute<?, ?> nextAttribute = getModelForPath(metamodel, property, managedTypeForModel, from);
+
+		if (nextAttribute == null) {
+			throw new IllegalStateException("Binding property is null");
+		}
+
+		return toExpressionRecursively(metamodel, joinSource, (Bindable<?>) nextAttribute, nextProperty, isForSelection,
+				requiresOuterJoin);
+	}
+
+	/**
+	 * Checks if this attribute requires an outer join. This is the case e.g. if it hadn't already been fetched with an
+	 * inner join and if it's an optional association, and if previous paths has already required outer joins. It also
+	 * ensures outer joins are used even when Hibernate defaults to inner joins (HHH-12712 and HHH-12999)
+	 *
+	 * @param metamodel
+	 * @param bindable
+	 * @param propertyPath
+	 * @param isForSelection
+	 * @param hasRequiredOuterJoin
+	 * @return
+	 */
+	static boolean requiresOuterJoin(@Nullable Metamodel metamodel, Bindable<?> bindable, PropertyPath propertyPath,
+			boolean isForSelection, boolean hasRequiredOuterJoin) {
+
+		ManagedType<?> managedType = QueryUtils.getManagedTypeForModel(bindable);
+		Attribute<?, ?> attribute = getModelForPath(metamodel, propertyPath, managedType, bindable);
+
+		boolean isPluralAttribute = bindable instanceof PluralAttribute;
+		if (attribute == null) {
+			return isPluralAttribute;
+		}
+
+		if (!QueryUtils.ASSOCIATION_TYPES.containsKey(attribute.getPersistentAttributeType())) {
+			return false;
+		}
+
+		boolean isCollection = attribute.isCollection();
+
+		// if this path is an optional one to one attribute navigated from the not owning side we also need an
+		// explicit outer join to avoid https://hibernate.atlassian.net/browse/HHH-12712
+		// and https://github.com/eclipse-ee4j/jpa-api/issues/170
+		boolean isInverseOptionalOneToOne = PersistentAttributeType.ONE_TO_ONE == attribute.getPersistentAttributeType()
+				&& StringUtils.hasText(QueryUtils.getAnnotationProperty(attribute, "mappedBy", ""));
+
+		boolean isLeafProperty = !propertyPath.hasNext();
+		if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) {
+			return false;
+		}
+
+		return hasRequiredOuterJoin || QueryUtils.getAnnotationProperty(attribute, "optional", true);
+	}
+
+	private static @Nullable Attribute<?, ?> getModelForPath(@Nullable Metamodel metamodel, PropertyPath path,
+			@Nullable ManagedType<?> managedType, Bindable<?> fallback) {
+
+		String segment = path.getSegment();
+		if (managedType != null) {
+			try {
+				return managedType.getAttribute(segment);
+			} catch (IllegalArgumentException ex) {
+				// ManagedType may be erased for some vendor if the attribute is declared as generic
+			}
+		}
+
+		if (metamodel != null) {
+
+			Class<?> fallbackType = fallback.getBindableJavaType();
+			try {
+				return metamodel.managedType(fallbackType).getAttribute(segment);
+			} catch (IllegalArgumentException e) {
+				// nothing to do here
+			}
+		}
+
+		return null;
+	}
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
index cea64d91ad..a80de6e4a3 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java
@@ -22,12 +22,13 @@
 import java.util.List;
 import java.util.Map;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.domain.KeysetScrollPosition;
 import org.springframework.data.domain.ScrollPosition.Direction;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Sort.Order;
 import org.springframework.data.jpa.repository.support.JpaEntityInformation;
-import org.springframework.lang.Nullable;
 
 /**
  * Delegate for keyset scrolling.
@@ -69,8 +70,7 @@ public static Collection<String> getProjectionInputProperties(JpaEntityInformati
 		return properties;
 	}
 
-	@Nullable
-	public <E, P> P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStrategy<E, P> strategy) {
+	public <E, P> @Nullable P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStrategy<E, P> strategy) {
 
 		Map<String, Object> keysetValues = keyset.getKeys();
 
@@ -104,7 +104,7 @@ public <E, P> P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStr
 					break;
 				}
 
-				sortConstraint.add(strategy.compare(propertyExpression, o));
+				sortConstraint.add(strategy.compare(inner.getProperty(), propertyExpression, o));
 				j++;
 			}
 
@@ -134,8 +134,31 @@ protected <T> List<T> getResultWindow(List<T> list, int limit) {
 		return CollectionUtils.getFirst(limit, list);
 	}
 
+	public Sort createSort(Sort sort, JpaEntityInformation<?, ?> entity) {
+
+		Collection<String> sortById;
+		Sort sortToUse;
+		if (entity.hasCompositeId()) {
+			sortById = new ArrayList<>(entity.getIdAttributeNames());
+		} else {
+			sortById = new ArrayList<>(1);
+			sortById.add(entity.getRequiredIdAttribute().getName());
+		}
+
+		sort.forEach(it -> sortById.remove(it.getProperty()));
+
+		if (sortById.isEmpty()) {
+			sortToUse = sort;
+		} else {
+			sortToUse = sort.and(Sort.by(sortById.toArray(new String[0])));
+		}
+
+		return getSortOrders(sortToUse);
+
+	}
+
 	/**
-	 * Reverse scrolling variant applying {@link Direction#Backward}. In reverse scrolling, we need to flip directions for
+	 * Reverse scrolling variant applying {@link Direction#BACKWARD}. In reverse scrolling, we need to flip directions for
 	 * the actual query so that we do not get everything from the top position and apply the limit but rather flip the
 	 * sort direction, apply the limit and then reverse the result to restore the actual sort order.
 	 */
@@ -184,19 +207,20 @@ public interface QueryStrategy<E, P> {
 		 *
 		 * @param order must not be {@literal null}.
 		 * @param propertyExpression must not be {@literal null}.
-		 * @param value the value to compare with. Must not be {@literal null}.
+		 * @param value the value to compare with. Can be {@literal null}.
 		 * @return an object representing the comparison predicate.
 		 */
-		P compare(Order order, E propertyExpression, Object value);
+		P compare(Order order, E propertyExpression, @Nullable Object value);
 
 		/**
 		 * Create an equals-comparison object.
 		 *
+		 * @param property name of the property.
 		 * @param propertyExpression must not be {@literal null}.
-		 * @param value the value to compare with. Must not be {@literal null}.
+		 * @param value the value to compare with. Can be {@literal null}.
 		 * @return an object representing the comparison predicate.
 		 */
-		P compare(E propertyExpression, @Nullable Object value);
+		P compare(String property, E propertyExpression, @Nullable Object value);
 
 		/**
 		 * AND-combine the {@code intermediate} predicates.
@@ -204,7 +228,7 @@ public interface QueryStrategy<E, P> {
 		 * @param intermediate the predicates to combine. Must not be {@literal null}.
 		 * @return a single predicate.
 		 */
-		P and(List<P> intermediate);
+		@Nullable P and(List<P> intermediate);
 
 		/**
 		 * OR-combine the {@code intermediate} predicates.
@@ -212,7 +236,7 @@ public interface QueryStrategy<E, P> {
 		 * @param intermediate the predicates to combine. Must not be {@literal null}.
 		 * @return a single predicate.
 		 */
-		P or(List<P> intermediate);
+		@Nullable P or(List<P> intermediate);
 	}
 
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java
index 40aa051983..76b3ed0a29 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecification.java
@@ -21,11 +21,13 @@
 import jakarta.persistence.criteria.From;
 import jakarta.persistence.criteria.Predicate;
 import jakarta.persistence.criteria.Root;
+import jakarta.persistence.metamodel.Bindable;
+import jakarta.persistence.metamodel.Metamodel;
 
-import java.util.ArrayList;
-import java.util.Collection;
 import java.util.List;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.domain.KeysetScrollPosition;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Sort.Order;
@@ -33,7 +35,6 @@
 import org.springframework.data.jpa.repository.query.KeysetScrollDelegate.QueryStrategy;
 import org.springframework.data.jpa.repository.support.JpaEntityInformation;
 import org.springframework.data.mapping.PropertyPath;
-import org.springframework.lang.Nullable;
 
 /**
  * {@link Specification} to create scroll queries using keyset-scrolling.
@@ -42,7 +43,7 @@
  * @author Christoph Strobl
  * @since 3.1
  */
-public record KeysetScrollSpecification<T> (KeysetScrollPosition position, Sort sort,
+public record KeysetScrollSpecification<T>(KeysetScrollPosition position, Sort sort,
 		JpaEntityInformation<?, ?> entity) implements Specification<T> {
 
 	public KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEntityInformation<?, ?> entity) {
@@ -63,45 +64,35 @@ public static Sort createSort(KeysetScrollPosition position, Sort sort, JpaEntit
 
 		KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection());
 
-		Collection<String> sortById;
-		Sort sortToUse;
-		if (entity.hasCompositeId()) {
-			sortById = new ArrayList<>(entity.getIdAttributeNames());
-		} else {
-			sortById = new ArrayList<>(1);
-			sortById.add(entity.getRequiredIdAttribute().getName());
-		}
-
-		sort.forEach(it -> sortById.remove(it.getProperty()));
-
-		if (sortById.isEmpty()) {
-			sortToUse = sort;
-		} else {
-			sortToUse = sort.and(Sort.by(sortById.toArray(new String[0])));
-		}
-
-		return delegate.getSortOrders(sortToUse);
+		return delegate.createSort(sort, entity);
 	}
 
 	@Override
-	public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
+	public @Nullable Predicate toPredicate(Root<T> root, @Nullable CriteriaQuery<?> query,
+			CriteriaBuilder criteriaBuilder) {
 		return createPredicate(root, criteriaBuilder);
 	}
 
-	@Nullable
-	public Predicate createPredicate(Root<?> root, CriteriaBuilder criteriaBuilder) {
+	public @Nullable Predicate createPredicate(Root<?> root, CriteriaBuilder criteriaBuilder) {
+
+		KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection());
+		return delegate.createPredicate(position, sort, new CriteriaBuilderStrategy(root, criteriaBuilder));
+	}
+
+	public JpqlQueryBuilder.@Nullable Predicate createJpqlPredicate(Bindable<?> from, JpqlQueryBuilder.Entity entity,
+			ParameterFactory factory) {
 
 		KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection());
-		return delegate.createPredicate(position, sort, new JpaQueryStrategy(root, criteriaBuilder));
+		return delegate.createPredicate(position, sort, new JpqlStrategy(null, from, entity, factory));
 	}
 
 	@SuppressWarnings("rawtypes")
-	private static class JpaQueryStrategy implements QueryStrategy<Expression<Comparable>, Predicate> {
+	private static class CriteriaBuilderStrategy implements QueryStrategy<Expression<Comparable>, Predicate> {
 
 		private final From<?, ?> from;
 		private final CriteriaBuilder cb;
 
-		public JpaQueryStrategy(From<?, ?> from, CriteriaBuilder cb) {
+		public CriteriaBuilderStrategy(From<?, ?> from, CriteriaBuilder cb) {
 
 			this.from = from;
 			this.cb = cb;
@@ -115,14 +106,18 @@ public Expression<Comparable> createExpression(String property) {
 		}
 
 		@Override
-		public Predicate compare(Order order, Expression<Comparable> propertyExpression, Object value) {
+		public Predicate compare(Order order, Expression<Comparable> propertyExpression, @Nullable Object value) {
+
+			if (value instanceof Comparable compareValue) {
+				return order.isAscending() ? cb.greaterThan(propertyExpression, compareValue)
+						: cb.lessThan(propertyExpression, compareValue);
+			}
+			return order.isAscending() ? cb.isNull(propertyExpression) : cb.isNotNull(propertyExpression);
 
-			return order.isAscending() ? cb.greaterThan(propertyExpression, (Comparable) value)
-					: cb.lessThan(propertyExpression, (Comparable) value);
 		}
 
 		@Override
-		public Predicate compare(Expression<Comparable> propertyExpression, @Nullable Object value) {
+		public Predicate compare(String property, Expression<Comparable> propertyExpression, @Nullable Object value) {
 			return value == null ? cb.isNull(propertyExpression) : cb.equal(propertyExpression, value);
 		}
 
@@ -136,4 +131,63 @@ public Predicate or(List<Predicate> intermediate) {
 			return cb.or(intermediate.toArray(new Predicate[0]));
 		}
 	}
+
+	private static class JpqlStrategy implements QueryStrategy<JpqlQueryBuilder.Expression, JpqlQueryBuilder.Predicate> {
+
+		private final Bindable<?> from;
+		private final JpqlQueryBuilder.Entity entity;
+		private final ParameterFactory factory;
+		private final @Nullable Metamodel metamodel;
+
+		public JpqlStrategy(@Nullable Metamodel metamodel, Bindable<?> from, JpqlQueryBuilder.Entity entity,
+				ParameterFactory factory) {
+
+			this.from = from;
+			this.entity = entity;
+			this.factory = factory;
+			this.metamodel = metamodel;
+		}
+
+		@Override
+		public JpqlQueryBuilder.Expression createExpression(String property) {
+
+			PropertyPath path = PropertyPath.from(property, from.getBindableJavaType());
+			return JpqlUtils.toExpressionRecursively(metamodel, entity, from, path);
+		}
+
+		@Override
+		public JpqlQueryBuilder.Predicate compare(Order order, JpqlQueryBuilder.Expression propertyExpression,
+				@Nullable Object value) {
+
+			JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(propertyExpression);
+			if (value == null) {
+				return order.isAscending() ? where.isNull() : where.isNotNull();
+			}
+			return order.isAscending() ? where.gt(factory.capture(order.getProperty(), value))
+					: where.lt(factory.capture(order.getProperty(), value));
+		}
+
+		@Override
+		public JpqlQueryBuilder.Predicate compare(String property, JpqlQueryBuilder.Expression propertyExpression,
+				@Nullable Object value) {
+
+			JpqlQueryBuilder.WhereStep where = JpqlQueryBuilder.where(propertyExpression);
+
+			return value == null ? where.isNull() : where.eq(factory.capture(property, value));
+		}
+
+		@Override
+		public JpqlQueryBuilder.@Nullable Predicate and(List<JpqlQueryBuilder.Predicate> intermediate) {
+			return JpqlQueryBuilder.and(intermediate);
+		}
+
+		@Override
+		public JpqlQueryBuilder.@Nullable Predicate or(List<JpqlQueryBuilder.Predicate> intermediate) {
+			return JpqlQueryBuilder.or(intermediate);
+		}
+	}
+
+	public interface ParameterFactory {
+		JpqlQueryBuilder.Expression capture(String name, Object value);
+	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java
index 53790bcf4f..a7e8dc35e6 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java
@@ -19,8 +19,9 @@
 import java.util.LinkedHashMap;
 import java.util.Map;
 
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.util.StringUtils;
 
 /**
@@ -69,8 +70,7 @@ public void setComment(String comment) {
 	/**
 	 * @return {@literal null} if not set.
 	 */
-	@Nullable
-	public String getComment() {
+	public @Nullable String getComment() {
 		return getValue(MetaKey.COMMENT.key);
 	}
 
@@ -106,9 +106,8 @@ void setValue(String key, @Nullable Object value) {
 		this.values.put(key, value);
 	}
 
-	@Nullable
 	@SuppressWarnings("unchecked")
-	private <T> T getValue(String key) {
+	private <T> @Nullable T getValue(String key) {
 		return (T) this.values.get(key);
 	}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java
index eeed1593fa..125ec40c66 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NamedQuery.java
@@ -22,6 +22,7 @@
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
+import org.jspecify.annotations.Nullable;
 
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.domain.Sort;
@@ -33,7 +34,7 @@
 import org.springframework.data.repository.query.ResultProcessor;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.util.Lazy;
-import org.springframework.lang.Nullable;
+import org.springframework.util.StringUtils;
 
 /**
  * Implementation of {@link RepositoryQuery} based on {@link jakarta.persistence.NamedQuery}s.
@@ -55,14 +56,13 @@ final class NamedQuery extends AbstractJpaQuery {
 	private final String countQueryName;
 	private final @Nullable String countProjection;
 	private final boolean namedCountQueryIsPresent;
-	private final Lazy<DeclaredQuery> declaredQuery;
-	private final QueryParameterSetter.QueryMetadataCache metadataCache;
+	private final Lazy<EntityQuery> entityQuery;
 	private final QueryRewriter queryRewriter;
 
 	/**
 	 * Creates a new {@link NamedQuery}.
 	 */
-	private NamedQuery(JpaQueryMethod method, EntityManager em, QueryRewriter queryRewriter) {
+	private NamedQuery(JpaQueryMethod method, EntityManager em, JpaQueryConfiguration queryConfiguration) {
 
 		super(method, em);
 
@@ -70,7 +70,7 @@ private NamedQuery(JpaQueryMethod method, EntityManager em, QueryRewriter queryR
 		this.countQueryName = method.getNamedCountQueryName();
 		QueryExtractor extractor = method.getQueryExtractor();
 		this.countProjection = method.getCountQueryProjection();
-		this.queryRewriter = queryRewriter;
+		this.queryRewriter = queryConfiguration.getQueryRewriter(method);
 
 		Parameters<?, ?> parameters = method.getParameters();
 
@@ -82,7 +82,7 @@ private NamedQuery(JpaQueryMethod method, EntityManager em, QueryRewriter queryR
 
 		this.namedCountQueryIsPresent = hasNamedQuery(em, countQueryName);
 
-		Query query = em.createNamedQuery(queryName);
+		Query namedQuery = em.createNamedQuery(queryName);
 		boolean weNeedToCreateCountQuery = !namedCountQueryIsPresent && method.getParameters().hasLimitingParameters();
 		boolean cantExtractQuery = !extractor.canExtractQuery();
 
@@ -96,11 +96,31 @@ private NamedQuery(JpaQueryMethod method, EntityManager em, QueryRewriter queryR
 					method, method.isNativeQuery() ? "NativeQuery" : "Query"));
 		}
 
-		String queryString = extractor.extractQueryString(query);
+		String queryString = extractor.extractQueryString(namedQuery);
 
-		this.declaredQuery = Lazy
-				.of(() -> DeclaredQuery.of(queryString, method.isNativeQuery() || query.toString().contains("NativeQuery")));
-		this.metadataCache = new QueryParameterSetter.QueryMetadataCache();
+		DeclaredQuery declaredQuery;
+		if (StringUtils.hasText(queryString)) {
+			if (method.isNativeQuery() || namedQuery.toString().contains("NativeQuery")) {
+				declaredQuery = DeclaredQuery.nativeQuery(queryString);
+			} else {
+				declaredQuery = DeclaredQuery.jpqlQuery(queryString);
+			}
+		}
+		else {
+			declaredQuery = new DeclaredQuery() {
+				@Override
+				public boolean isNative() {
+					return false;
+				}
+
+				@Override
+				public String getQueryString() {
+					return "";
+				}
+			};
+		}
+
+		this.entityQuery = Lazy.of(() -> EntityQuery.create(declaredQuery, queryConfiguration.getSelector()));
 	}
 
 	/**
@@ -133,10 +153,11 @@ static boolean hasNamedQuery(EntityManager em, String queryName) {
 	 *
 	 * @param method must not be {@literal null}.
 	 * @param em must not be {@literal null}.
-	 * @param queryRewriter must not be {@literal null}.
+	 * @param selector must not be {@literal null}.
+	 * @param queryConfiguration must not be {@literal null}.
 	 */
-	@Nullable
-	public static RepositoryQuery lookupFrom(JpaQueryMethod method, EntityManager em, QueryRewriter queryRewriter) {
+	public static @Nullable RepositoryQuery lookupFrom(JpaQueryMethod method, EntityManager em,
+			JpaQueryConfiguration queryConfiguration) {
 
 		String queryName = method.getNamedQueryName();
 
@@ -154,13 +175,18 @@ public static RepositoryQuery lookupFrom(JpaQueryMethod method, EntityManager em
 					method.isNativeQuery() ? "NativeQuery" : "Query"));
 		}
 
-		RepositoryQuery query = new NamedQuery(method, em, queryRewriter);
+		RepositoryQuery query = new NamedQuery(method, em, queryConfiguration);
 		if (LOG.isDebugEnabled()) {
 			LOG.debug(String.format("Found named query '%s'", queryName));
 		}
 		return query;
 	}
 
+	@Override
+	public boolean hasDeclaredCountQuery() {
+		return namedCountQueryIsPresent;
+	}
+
 	@Override
 	protected Query doCreateQuery(JpaParametersParameterAccessor accessor) {
 
@@ -175,9 +201,7 @@ protected Query doCreateQuery(JpaParametersParameterAccessor accessor) {
 				? em.createNamedQuery(queryName) //
 				: em.createNamedQuery(queryName, typeToRead);
 
-		QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(queryName, query);
-
-		return parameterBinder.get().bindAndPrepare(query, metadata, accessor);
+		return parameterBinder.get().bindAndPrepare(query, accessor);
 	}
 
 	@Override
@@ -186,26 +210,20 @@ protected TypedQuery<Long> doCreateCountQuery(JpaParametersParameterAccessor acc
 		EntityManager em = getEntityManager();
 		TypedQuery<Long> countQuery;
 
-		String cacheKey;
 		if (namedCountQueryIsPresent) {
-			cacheKey = countQueryName;
 			countQuery = em.createNamedQuery(countQueryName, Long.class);
-
 		} else {
 
-			String countQueryString = declaredQuery.get().deriveCountQuery(countProjection).getQueryString();
+			String countQueryString = entityQuery.get().deriveCountQuery(countProjection).getQueryString();
 			countQueryString = potentiallyRewriteQuery(countQueryString, accessor.getSort(), accessor.getPageable());
-			cacheKey = countQueryString;
 			countQuery = em.createQuery(countQueryString, Long.class);
 		}
 
-		QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(cacheKey, countQuery);
-
-		return parameterBinder.get().bind(countQuery, metadata, accessor);
+		return parameterBinder.get().bind(countQuery, accessor);
 	}
 
 	@Override
-	protected Class<?> getTypeToRead(ReturnedType returnedType) {
+	protected @Nullable Class<?> getTypeToRead(ReturnedType returnedType) {
 
 		if (getQueryMethod().isNativeQuery()) {
 
@@ -226,7 +244,7 @@ protected Class<?> getTypeToRead(ReturnedType returnedType) {
 			return type.isInterface() ? Tuple.class : null;
 		}
 
-		return declaredQuery.get().hasConstructorExpression() //
+		return entityQuery.get().hasConstructorExpression() //
 				? null //
 				: super.getTypeToRead(returnedType);
 	}
@@ -240,9 +258,9 @@ protected Class<?> getTypeToRead(ReturnedType returnedType) {
 	 * @param pageable
 	 * @return
 	 */
-	private String potentiallyRewriteQuery(String originalQuery, Sort sort, Pageable pageable) {
+	private String potentiallyRewriteQuery(String originalQuery, Sort sort, @Nullable Pageable pageable) {
 
-		return pageable.isPaged() //
+		return pageable != null && pageable.isPaged() //
 				? queryRewriter.rewrite(originalQuery, pageable) //
 				: queryRewriter.rewrite(originalQuery, sort);
 	}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java
index 9221cc3807..35045c5e25 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java
@@ -19,16 +19,15 @@
 import jakarta.persistence.Query;
 import jakarta.persistence.Tuple;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.core.annotation.MergedAnnotation;
 import org.springframework.core.annotation.MergedAnnotations;
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jpa.repository.NativeQuery;
-import org.springframework.data.jpa.repository.QueryRewriter;
 import org.springframework.data.repository.query.RepositoryQuery;
 import org.springframework.data.repository.query.ReturnedType;
-import org.springframework.data.repository.query.ValueExpressionDelegate;
-import org.springframework.lang.Nullable;
 import org.springframework.util.ObjectUtils;
 
 /**
@@ -42,7 +41,7 @@
  * @author Mark Paluch
  * @author Greg Turnquist
  */
-final class NativeJpaQuery extends AbstractStringBasedJpaQuery {
+class NativeJpaQuery extends AbstractStringBasedJpaQuery {
 
 	private final @Nullable String sqlResultSetMapping;
 
@@ -55,26 +54,47 @@ final class NativeJpaQuery extends AbstractStringBasedJpaQuery {
 	 * @param em must not be {@literal null}.
 	 * @param queryString must not be {@literal null} or empty.
 	 * @param countQueryString must not be {@literal null} or empty.
-	 * @param rewriter the query rewriter to use.
-	 * @param valueExpressionDelegate must not be {@literal null}.
+	 * @param queryConfiguration must not be {@literal null}.
 	 */
-	public NativeJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, @Nullable String countQueryString,
-			QueryRewriter rewriter, ValueExpressionDelegate valueExpressionDelegate) {
+	NativeJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, @Nullable String countQueryString,
+			JpaQueryConfiguration queryConfiguration) {
 
-		super(method, em, queryString, countQueryString, rewriter, valueExpressionDelegate);
+		super(method, em, queryString, countQueryString, queryConfiguration);
 
 		MergedAnnotations annotations = MergedAnnotations.from(method.getMethod());
 		MergedAnnotation<NativeQuery> annotation = annotations.get(NativeQuery.class);
+
 		this.sqlResultSetMapping = annotation.isPresent() ? annotation.getString("sqlResultSetMapping") : null;
+		this.queryForEntity = getQueryMethod().isQueryForEntity();
+	}
+
+	/**
+	 * Creates a new {@link NativeJpaQuery} encapsulating the query annotated on the given {@link JpaQueryMethod}.
+	 *
+	 * @param method must not be {@literal null}.
+	 * @param em must not be {@literal null}.
+	 * @param query must not be {@literal null} .
+	 * @param countQuery can be {@literal null} if not defined.
+	 * @param queryConfiguration must not be {@literal null}.
+	 */
+	public NativeJpaQuery(JpaQueryMethod method, EntityManager em, DeclaredQuery query,
+			@Nullable DeclaredQuery countQuery, JpaQueryConfiguration queryConfiguration) {
+
+		super(method, em, query, countQuery, queryConfiguration);
 
+		MergedAnnotations annotations = MergedAnnotations.from(method.getMethod());
+		MergedAnnotation<NativeQuery> annotation = annotations.get(NativeQuery.class);
+
+		this.sqlResultSetMapping = annotation.isPresent() ? annotation.getString("sqlResultSetMapping") : null;
 		this.queryForEntity = getQueryMethod().isQueryForEntity();
 	}
 
 	@Override
-	protected Query createJpaQuery(String queryString, Sort sort, Pageable pageable, ReturnedType returnedType) {
+	protected Query createJpaQuery(QueryProvider declaredQuery, Sort sort, @Nullable Pageable pageable,
+			ReturnedType returnedType) {
 
 		EntityManager em = getEntityManager();
-		String query = potentiallyRewriteQuery(queryString, sort, pageable);
+		String query = potentiallyRewriteQuery(declaredQuery.getQueryString(), sort, pageable);
 
 		if (!ObjectUtils.isEmpty(sqlResultSetMapping)) {
 			return em.createNativeQuery(query, sqlResultSetMapping);
@@ -84,8 +104,7 @@ protected Query createJpaQuery(String queryString, Sort sort, Pageable pageable,
 		return type == null ? em.createNativeQuery(query) : em.createNativeQuery(query, type);
 	}
 
-	@Nullable
-	private Class<?> getTypeToQueryFor(ReturnedType returnedType) {
+	private @Nullable Class<?> getTypeToQueryFor(ReturnedType returnedType) {
 
 		Class<?> result = queryForEntity ? returnedType.getDomainType() : null;
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java
index 7a49f584a1..8c7c458852 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java
@@ -21,6 +21,7 @@
 import org.springframework.data.jpa.repository.query.QueryParameterSetter.ErrorHandling;
 import org.springframework.data.jpa.support.PageableUtils;
 import org.springframework.util.Assert;
+import org.springframework.util.ErrorHandler;
 
 /**
  * {@link ParameterBinder} is used to bind method parameters to a {@link Query}. This is usually done whenever an
@@ -33,7 +34,7 @@
  * @author Jens Schauder
  * @author Yanming Zhou
  */
-public class ParameterBinder {
+class ParameterBinder {
 
 	static final String PARAMETER_NEEDS_TO_BE_NAMED = "For queries with named parameters you need to provide names for method parameters; Use @Param for query method parameters, or when on Java 8+ use the javac flag -parameters";
 
@@ -72,18 +73,18 @@ public ParameterBinder(JpaParameters parameters, Iterable<QueryParameterSetter>
 		this.useJpaForPaging = useJpaForPaging;
 	}
 
-	public <T extends Query> T bind(T jpaQuery, QueryParameterSetter.QueryMetadata metadata,
+	public <T extends Query> T bind(T jpaQuery,
 			JpaParametersParameterAccessor accessor) {
 
-		bind(metadata.withQuery(jpaQuery), accessor, ErrorHandling.STRICT);
+		bind(new QueryParameterSetter.BindableQuery(jpaQuery), accessor, ErrorHandling.STRICT);
 		return jpaQuery;
 	}
 
 	public void bind(QueryParameterSetter.BindableQuery query, JpaParametersParameterAccessor accessor,
-			ErrorHandling errorHandling) {
+			ErrorHandler errorHandler) {
 
 		for (QueryParameterSetter setter : parameterSetters) {
-			setter.setParameter(query, accessor, errorHandling);
+			setter.setParameter(query, accessor, errorHandler);
 		}
 	}
 
@@ -91,13 +92,12 @@ public void bind(QueryParameterSetter.BindableQuery query, JpaParametersParamete
 	 * Binds the parameters to the given query and applies special parameter types (e.g. pagination).
 	 *
 	 * @param query must not be {@literal null}.
-	 * @param metadata must not be {@literal null}.
 	 * @param accessor must not be {@literal null}.
 	 */
-	Query bindAndPrepare(Query query, QueryParameterSetter.QueryMetadata metadata,
+	Query bindAndPrepare(Query query,
 			JpaParametersParameterAccessor accessor) {
 
-		bind(query, metadata, accessor);
+		bind(query, accessor);
 
 		Pageable pageable = accessor.getPageable();
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java
index 21a715e07f..00aef26195 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java
@@ -23,7 +23,6 @@
 import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter;
 import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier;
 import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin;
-import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata;
 import org.springframework.util.Assert;
 
 /**
@@ -40,37 +39,37 @@ class ParameterBinderFactory {
 	 * otherwise.
 	 *
 	 * @param parameters method parameters that are available for binding, must not be {@literal null}.
+	 * @param preferNamedParameters
 	 * @return a {@link ParameterBinder} that can assign values for the method parameters to query parameters of a
 	 *         {@link jakarta.persistence.Query}
 	 */
-	static ParameterBinder createBinder(JpaParameters parameters) {
+	static ParameterBinder createBinder(JpaParameters parameters, boolean preferNamedParameters) {
 
 		Assert.notNull(parameters, "JpaParameters must not be null");
 
-		QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters);
+		QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters, preferNamedParameters);
 		List<ParameterBinding> bindings = getBindings(parameters);
 
 		return new ParameterBinder(parameters, createSetters(bindings, setterFactory));
 	}
 
 	/**
-	 * Creates a {@link ParameterBinder} that just matches method parameter to parameters of a
-	 * {@link jakarta.persistence.criteria.CriteriaQuery}.
+	 * Creates a {@link ParameterBinder} that matches method parameter to parameters of a
+	 * {@link jakarta.persistence.Query} and that can bind synthetic parameters.
 	 *
 	 * @param parameters method parameters that are available for binding, must not be {@literal null}.
-	 * @param metadata must not be {@literal null}.
+	 * @param bindings parameter bindings for method argument and synthetic parameters, must not be {@literal null}.
 	 * @return a {@link ParameterBinder} that can assign values for the method parameters to query parameters of a
-	 *         {@link jakarta.persistence.criteria.CriteriaQuery}
+	 *         {@link jakarta.persistence.Query}
 	 */
-	static ParameterBinder createCriteriaBinder(JpaParameters parameters, List<ParameterMetadata<?>> metadata) {
+	static ParameterBinder createBinder(JpaParameters parameters, List<ParameterBinding> bindings) {
 
 		Assert.notNull(parameters, "JpaParameters must not be null");
-		Assert.notNull(metadata, "Parameter metadata must not be null");
-
-		QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.forCriteriaQuery(parameters, metadata);
-		List<ParameterBinding> bindings = getBindings(parameters);
+		Assert.notNull(bindings, "Parameter bindings must not be null");
 
-		return new ParameterBinder(parameters, createSetters(bindings, setterFactory));
+		return new ParameterBinder(parameters,
+				createSetters(bindings, QueryParameterSetterFactory.forPartTreeQuery(parameters),
+						QueryParameterSetterFactory.forSynthetic()));
 	}
 
 	/**
@@ -79,13 +78,13 @@ static ParameterBinder createCriteriaBinder(JpaParameters parameters, List<Param
 	 * query in order to ensure that all query parameters are bound.
 	 *
 	 * @param parameters method parameters that are available for binding, must not be {@literal null}.
-	 * @param query the {@link StringQuery} the binders shall be created for, must not be {@literal null}.
+	 * @param query the {@link DefaultEntityQuery} the binders shall be created for, must not be {@literal null}.
 	 * @param parser must not be {@literal null}.
 	 * @param evaluationContextProvider must not be {@literal null}.
 	 * @return a {@link ParameterBinder} that can assign values for the method parameters to query parameters of a
 	 *         {@link jakarta.persistence.Query} while processing SpEL expressions where applicable.
 	 */
-	static ParameterBinder createQueryAwareBinder(JpaParameters parameters, DeclaredQuery query,
+	static ParameterBinder createQueryAwareBinder(JpaParameters parameters, ParametrizedQuery query,
 			ValueExpressionParser parser, ValueEvaluationContextProvider evaluationContextProvider) {
 
 		Assert.notNull(parameters, "JpaParameters must not be null");
@@ -93,19 +92,22 @@ static ParameterBinder createQueryAwareBinder(JpaParameters parameters, Declared
 		Assert.notNull(parser, "SpelExpressionParser must not be null");
 		Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null");
 
-		List<ParameterBinding> bindings = query.getParameterBindings();
 		QueryParameterSetterFactory expressionSetterFactory = QueryParameterSetterFactory.parsing(parser,
 				evaluationContextProvider);
 
-		QueryParameterSetterFactory basicSetterFactory = QueryParameterSetterFactory.basic(parameters);
+		QueryParameterSetterFactory basicSetterFactory = QueryParameterSetterFactory.basic(parameters,
+				query.hasNamedParameter());
+
+		boolean usesPaging = query instanceof EntityQuery eq && eq.usesPaging();
 
-		return new ParameterBinder(parameters, createSetters(bindings, query, expressionSetterFactory, basicSetterFactory),
-				!query.usesPaging());
+		// TODO: lets maybe obtain the bindable query and pass that on to create the setters?
+		return new ParameterBinder(parameters, createSetters(query.getParameterBindings(), query, expressionSetterFactory, basicSetterFactory),
+				!usesPaging);
 	}
 
-	private static List<ParameterBinding> getBindings(JpaParameters parameters) {
+	static List<ParameterBinding> getBindings(JpaParameters parameters) {
 
-		List<ParameterBinding> result = new ArrayList<>();
+		List<ParameterBinding> result = new ArrayList<>(parameters.getNumberOfParameters());
 		int bindableParameterIndex = 0;
 
 		for (JpaParameter parameter : parameters) {
@@ -124,26 +126,26 @@ private static List<ParameterBinding> getBindings(JpaParameters parameters) {
 
 	private static Iterable<QueryParameterSetter> createSetters(List<ParameterBinding> parameterBindings,
 			QueryParameterSetterFactory... factories) {
-		return createSetters(parameterBindings, EmptyDeclaredQuery.EMPTY_QUERY, factories);
+		return createSetters(parameterBindings, EmptyIntrospectedQuery.INSTANCE, factories);
 	}
 
 	private static Iterable<QueryParameterSetter> createSetters(List<ParameterBinding> parameterBindings,
-			DeclaredQuery declaredQuery, QueryParameterSetterFactory... strategies) {
+			ParametrizedQuery query, QueryParameterSetterFactory... strategies) {
 
 		List<QueryParameterSetter> setters = new ArrayList<>(parameterBindings.size());
 		for (ParameterBinding parameterBinding : parameterBindings) {
-			setters.add(createQueryParameterSetter(parameterBinding, strategies, declaredQuery));
+			setters.add(createQueryParameterSetter(parameterBinding, strategies, query));
 		}
 
 		return setters;
 	}
 
 	private static QueryParameterSetter createQueryParameterSetter(ParameterBinding binding,
-			QueryParameterSetterFactory[] strategies, DeclaredQuery declaredQuery) {
+			QueryParameterSetterFactory[] strategies, ParametrizedQuery query) {
 
 		for (QueryParameterSetterFactory strategy : strategies) {
 
-			QueryParameterSetter setter = strategy.create(binding, declaredQuery);
+			QueryParameterSetter setter = strategy.create(binding, query);
 
 			if (setter != null) {
 				return setter;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java
index e5cffccaf6..040e84a8ed 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinding.java
@@ -21,13 +21,21 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
+import java.util.stream.Collectors;
+
+import org.jspecify.annotations.Nullable;
 
 import org.springframework.data.expression.ValueExpression;
 import org.springframework.data.jpa.provider.PersistenceProvider;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
+import org.springframework.data.repository.query.Parameter;
+import org.springframework.data.repository.query.parser.Part;
 import org.springframework.data.repository.query.parser.Part.Type;
-import org.springframework.lang.Nullable;
+import org.springframework.lang.Contract;
 import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
 import org.springframework.util.ObjectUtils;
 import org.springframework.util.StringUtils;
 
@@ -36,8 +44,9 @@
  *
  * @author Thomas Darimont
  * @author Mark Paluch
+ * @author Christoph Strobl
  */
-class ParameterBinding {
+public class ParameterBinding {
 
 	private final BindingIdentifier identifier;
 	private final ParameterOrigin origin;
@@ -68,11 +77,18 @@ public ParameterOrigin getOrigin() {
 	/**
 	 * @return the name if available or {@literal null}.
 	 */
-	@Nullable
-	public String getName() {
+	public @Nullable String getName() {
 		return identifier.hasName() ? identifier.getName() : null;
 	}
 
+	/**
+	 * @return {@literal true} if the binding identifier is associated with a name.
+	 * @since 4.0
+	 */
+	boolean hasName() {
+		return identifier.hasName();
+	}
+
 	/**
 	 * @return the name
 	 * @throws IllegalStateException if the name is not available.
@@ -143,8 +159,7 @@ public String toString() {
 	/**
 	 * @param valueToBind value to prepare
 	 */
-	@Nullable
-	public Object prepare(@Nullable Object valueToBind) {
+	public @Nullable Object prepare(@Nullable Object valueToBind) {
 		return valueToBind;
 	}
 
@@ -186,6 +201,113 @@ public boolean isCompatibleWith(ParameterBinding other) {
 		return other.getClass() == getClass() && other.getOrigin().equals(getOrigin());
 	}
 
+	/**
+	 * Represents a {@link ParameterBinding} in a JPQL query augmented with instructions of how to apply a parameter as an
+	 * {@code IN} parameter.
+	 *
+	 * @author Thomas Darimont
+	 * @author Mark Paluch
+	 */
+	static class PartTreeParameterBinding extends ParameterBinding {
+
+		private final Class<?> parameterType;
+		private final JpqlQueryTemplates templates;
+		private final EscapeCharacter escape;
+		private final Type type;
+		private final boolean ignoreCase;
+		private final boolean noWildcards;
+
+		public PartTreeParameterBinding(BindingIdentifier identifier, ParameterOrigin origin, Class<?> parameterType,
+				Part part, @Nullable Object value, JpqlQueryTemplates templates, EscapeCharacter escape) {
+
+			super(identifier, origin);
+
+			this.parameterType = parameterType;
+			this.templates = templates;
+			this.escape = escape;
+
+			this.type = value == null
+					&& (Type.SIMPLE_PROPERTY.equals(part.getType()) || Type.NEGATING_SIMPLE_PROPERTY.equals(part.getType()))
+							? Type.IS_NULL
+							: part.getType();
+			this.ignoreCase = Part.IgnoreCaseType.ALWAYS.equals(part.shouldIgnoreCase());
+			this.noWildcards = part.getProperty().getLeafProperty().isCollection();
+		}
+
+		/**
+		 * Returns whether the parameter shall be considered an {@literal IS NULL} parameter.
+		 */
+		public boolean isIsNullParameter() {
+			return Type.IS_NULL.equals(type);
+		}
+
+		@Override
+		public @Nullable Object prepare(@Nullable Object value) {
+
+			if (value == null || parameterType == null) {
+				return value;
+			}
+
+			if (String.class.equals(parameterType) && !noWildcards) {
+
+				return switch (type) {
+					case STARTING_WITH -> String.format("%s%%", escape.escape(value.toString()));
+					case ENDING_WITH -> String.format("%%%s", escape.escape(value.toString()));
+					case CONTAINING, NOT_CONTAINING -> String.format("%%%s%%", escape.escape(value.toString()));
+					default -> value;
+				};
+			}
+
+			return Collection.class.isAssignableFrom(parameterType) //
+					? potentiallyIgnoreCase(ignoreCase, toCollection(value)) //
+					: value;
+		}
+
+
+		@SuppressWarnings("unchecked")
+		@Contract("false, _ -> param2; _, null -> null; true, !null -> new)")
+		private @Nullable Collection<?> potentiallyIgnoreCase(boolean ignoreCase, @Nullable Collection<?> collection) {
+
+			if (!ignoreCase || CollectionUtils.isEmpty(collection)) {
+				return collection;
+			}
+
+			return ((Collection<String>) collection).stream() //
+					.map(it -> it == null //
+							? null //
+							: templates.ignoreCase(it)) //
+					.collect(Collectors.toList());
+		}
+
+		/**
+		 * Returns the given argument as {@link Collection} which means it will return it as is if it's a
+		 * {@link Collections}, turn an array into an {@link ArrayList} or simply wrap any other value into a single element
+		 * {@link Collections}.
+		 *
+		 * @param value the value to be converted to a {@link Collection}.
+		 * @return the object itself as a {@link Collection} or a {@link Collection} constructed from the value.
+		 */
+		private static @Nullable Collection<?> toCollection(@Nullable Object value) {
+
+			if (value == null) {
+				return null;
+			}
+
+			if (value instanceof Collection<?> collection) {
+				return collection.isEmpty() ? null : collection;
+			}
+
+			if (ObjectUtils.isArray(value)) {
+
+				List<Object> collection = Arrays.asList(ObjectUtils.toObjectArray(value));
+				return collection.isEmpty() ? null : collection;
+			}
+
+			return Collections.singleton(value);
+		}
+
+	}
+
 	/**
 	 * Represents a {@link ParameterBinding} in a JPQL query augmented with instructions of how to apply a parameter as an
 	 * {@code IN} parameter.
@@ -202,7 +324,7 @@ static class InParameterBinding extends ParameterBinding {
 		}
 
 		@Override
-		public Object prepare(@Nullable Object value) {
+		public @Nullable Object prepare(@Nullable Object value) {
 
 			if (!ObjectUtils.isArray(value)) {
 				return value;
@@ -264,9 +386,8 @@ public Type getType() {
 		/**
 		 * Extracts the raw value properly.
 		 */
-		@Nullable
 		@Override
-		public Object prepare(@Nullable Object value) {
+		public @Nullable Object prepare(@Nullable Object value) {
 
 			Object unwrapped = PersistenceProvider.unwrapTypedParameterValue(value);
 			if (unwrapped == null) {
@@ -349,7 +470,7 @@ static Type getLikeTypeFrom(String expression) {
 	 * @author Mark Paluch
 	 * @since 3.1.2
 	 */
-	sealed interface BindingIdentifier permits Named,Indexed,NamedAndIndexed {
+	public sealed interface BindingIdentifier permits Named, Indexed, NamedAndIndexed {
 
 		/**
 		 * Creates an identifier for the given {@code name}.
@@ -495,7 +616,7 @@ public String toString() {
 	 * @author Mark Paluch
 	 * @since 3.1.2
 	 */
-	sealed interface ParameterOrigin permits Expression,MethodInvocationArgument {
+	public sealed interface ParameterOrigin permits Expression, MethodInvocationArgument, Synthetic {
 
 		/**
 		 * Creates a {@link Expression} for the given {@code expression}.
@@ -507,6 +628,17 @@ static Expression ofExpression(ValueExpression expression) {
 			return new Expression(expression);
 		}
 
+		/**
+		 * Creates a {@link Expression} for the given {@code expression} string.
+		 *
+		 * @param value the captured value.
+		 * @param source source from which this value is derived.
+		 * @return {@link Synthetic} for the given {@code value}.
+		 */
+		static Synthetic synthetic(@Nullable Object value, Object source) {
+			return new Synthetic(value, source);
+		}
+
 		/**
 		 * Creates a {@link MethodInvocationArgument} object for {@code name}
 		 *
@@ -532,13 +664,25 @@ static MethodInvocationArgument ofParameter(@Nullable String name, @Nullable Int
 				identifier = BindingIdentifier.of(name, position);
 			} else if (!ObjectUtils.isEmpty(name)) {
 				identifier = BindingIdentifier.of(name);
-			} else {
+			} else if (position != null) {
 				identifier = BindingIdentifier.of(position);
+			} else {
+				throw new IllegalStateException("Neither name nor position available for binding");
 			}
 
 			return ofParameter(identifier);
 		}
 
+		/**
+		 * Creates a {@link MethodInvocationArgument} object for {@code position}.
+		 *
+		 * @param parameter the parameter from the method invocation.
+		 * @return {@link MethodInvocationArgument} object for {@code position}.
+		 */
+		static MethodInvocationArgument ofParameter(Parameter parameter) {
+			return ofParameter(parameter.getIndex() + 1);
+		}
+
 		/**
 		 * Creates a {@link MethodInvocationArgument} object for {@code position}.
 		 *
@@ -568,6 +712,11 @@ static MethodInvocationArgument ofParameter(BindingIdentifier identifier) {
 		 * @return {@code true} if the origin is an expression.
 		 */
 		boolean isExpression();
+
+		/**
+		 * @return {@code true} if the origin is synthetic (contributed by e.g. KeysetPagination)
+		 */
+		boolean isSynthetic();
 	}
 
 	/**
@@ -588,6 +737,36 @@ public boolean isMethodArgument() {
 		public boolean isExpression() {
 			return true;
 		}
+
+		@Override
+		public boolean isSynthetic() {
+			return true;
+		}
+	}
+
+	/**
+	 * Value object capturing the expression of which a binding parameter originates.
+	 *
+	 * @param value
+	 * @param source
+	 * @author Mark Paluch
+	 */
+	public record Synthetic(@Nullable Object value, Object source) implements ParameterOrigin {
+
+		@Override
+		public boolean isMethodArgument() {
+			return false;
+		}
+
+		@Override
+		public boolean isExpression() {
+			return false;
+		}
+
+		@Override
+		public boolean isSynthetic() {
+			return true;
+		}
 	}
 
 	/**
@@ -608,5 +787,10 @@ public boolean isMethodArgument() {
 		public boolean isExpression() {
 			return false;
 		}
+
+		@Override
+		public boolean isSynthetic() {
+			return false;
+		}
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java
index aa96a30163..72d43ab5bd 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterMetadataProvider.java
@@ -15,19 +15,24 @@
  */
 package org.springframework.data.jpa.repository.query;
 
+import static org.springframework.data.jpa.repository.query.ParameterBinding.*;
+
 import jakarta.persistence.criteria.CriteriaBuilder;
-import jakarta.persistence.criteria.ParameterExpression;
 
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
+import java.util.LinkedHashSet;
 import java.util.List;
-import java.util.function.Supplier;
+import java.util.Set;
 import java.util.stream.Collectors;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.jpa.provider.PersistenceProvider;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
 import org.springframework.data.repository.query.Parameter;
 import org.springframework.data.repository.query.Parameters;
 import org.springframework.data.repository.query.ParametersParameterAccessor;
@@ -35,7 +40,6 @@
 import org.springframework.data.repository.query.parser.Part.IgnoreCaseType;
 import org.springframework.data.repository.query.parser.Part.Type;
 import org.springframework.expression.Expression;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
 import org.springframework.util.CollectionUtils;
@@ -56,83 +60,88 @@
  */
 public class ParameterMetadataProvider {
 
-	private final CriteriaBuilder builder;
 	private final Iterator<? extends Parameter> parameters;
-	private final List<ParameterMetadata<?>> expressions;
+	private final List<ParameterBinding> bindings;
+	private final Set<String> syntheticParameterNames = new LinkedHashSet<>();
 	private final @Nullable Iterator<Object> bindableParameterValues;
 	private final EscapeCharacter escape;
+	private final JpqlQueryTemplates templates;
+	private final JpaParameters jpaParameters;
+	private int position;
 
 	/**
 	 * Creates a new {@link ParameterMetadataProvider} from the given {@link CriteriaBuilder} and
 	 * {@link ParametersParameterAccessor}.
 	 *
-	 * @param builder must not be {@literal null}.
 	 * @param accessor must not be {@literal null}.
 	 * @param escape must not be {@literal null}.
+	 * @param templates must not be {@literal null}.
 	 */
-	public ParameterMetadataProvider(CriteriaBuilder builder, ParametersParameterAccessor accessor,
-			EscapeCharacter escape) {
-		this(builder, accessor.iterator(), accessor.getParameters(), escape);
+	public ParameterMetadataProvider(JpaParametersParameterAccessor accessor,
+			EscapeCharacter escape, JpqlQueryTemplates templates) {
+		this(accessor.iterator(), accessor.getParameters(), escape, templates);
 	}
 
 	/**
 	 * Creates a new {@link ParameterMetadataProvider} from the given {@link CriteriaBuilder} and {@link Parameters} with
 	 * support for parameter value customizations via {@link PersistenceProvider}.
 	 *
-	 * @param builder must not be {@literal null}.
 	 * @param parameters must not be {@literal null}.
 	 * @param escape must not be {@literal null}.
+	 * @param templates must not be {@literal null}.
 	 */
-	public ParameterMetadataProvider(CriteriaBuilder builder, Parameters<?, ?> parameters, EscapeCharacter escape) {
-		this(builder, null, parameters, escape);
+	public ParameterMetadataProvider(JpaParameters parameters, EscapeCharacter escape,
+			JpqlQueryTemplates templates) {
+		this(null, parameters, escape, templates);
 	}
 
 	/**
 	 * Creates a new {@link ParameterMetadataProvider} from the given {@link CriteriaBuilder} an {@link Iterable} of all
 	 * bindable parameter values, and {@link Parameters}.
 	 *
-	 * @param builder must not be {@literal null}.
 	 * @param bindableParameterValues may be {@literal null}.
 	 * @param parameters must not be {@literal null}.
 	 * @param escape must not be {@literal null}.
+	 * @param templates must not be {@literal null}.
 	 */
-	private ParameterMetadataProvider(CriteriaBuilder builder, @Nullable Iterator<Object> bindableParameterValues,
-			Parameters<?, ?> parameters, EscapeCharacter escape) {
+	private ParameterMetadataProvider(@Nullable Iterator<Object> bindableParameterValues, JpaParameters parameters,
+			EscapeCharacter escape, JpqlQueryTemplates templates) {
 
-		Assert.notNull(builder, "CriteriaBuilder must not be null");
 		Assert.notNull(parameters, "Parameters must not be null");
 		Assert.notNull(escape, "EscapeCharacter must not be null");
+		Assert.notNull(templates, "JpqlQueryTemplates must not be null");
 
-		this.builder = builder;
+		this.jpaParameters = parameters;
 		this.parameters = parameters.getBindableParameters().iterator();
-		this.expressions = new ArrayList<>();
+		this.bindings = new ArrayList<>();
 		this.bindableParameterValues = bindableParameterValues;
 		this.escape = escape;
+		this.templates = templates;
 	}
 
 	/**
-	 * Returns all {@link ParameterMetadata}s built.
+	 * Returns all {@link ParameterBinding}s built.
 	 *
-	 * @return the expressions
+	 * @return the bindings.
 	 */
-	public List<ParameterMetadata<?>> getExpressions() {
-		return expressions;
+	public List<ParameterBinding> getBindings() {
+		return bindings;
 	}
 
 	/**
-	 * Builds a new {@link ParameterMetadata} for given {@link Part} and the next {@link Parameter}.
+	 * Builds a new {@link PartTreeParameterBinding} for given {@link Part} and the next {@link Parameter}.
 	 */
 	@SuppressWarnings("unchecked")
-	public <T> ParameterMetadata<T> next(Part part) {
+	public <T> PartTreeParameterBinding next(Part part) {
 
 		Assert.isTrue(parameters.hasNext(), () -> String.format("No parameter available for part %s", part));
 
 		Parameter parameter = parameters.next();
-		return (ParameterMetadata<T>) next(part, parameter.getType(), parameter);
+		return next(part, parameter.getType(), parameter);
 	}
 
 	/**
-	 * Builds a new {@link ParameterMetadata} of the given {@link Part} and type. Forwards the underlying
+	 * Builds a new {@link PartTreeParameterBinding} of the given {@link Part} and type. Forwards the underlying
 	 * {@link Parameters} as well.
 	 *
 	 * @param <T> is the type parameter of the returned {@link ParameterMetadata}.
@@ -140,15 +149,15 @@ public <T> ParameterMetadata<T> next(Part part) {
 	 * @return ParameterMetadata for the next parameter.
 	 */
 	@SuppressWarnings("unchecked")
-	public <T> ParameterMetadata<? extends T> next(Part part, Class<T> type) {
+	public <T> PartTreeParameterBinding next(Part part, Class<T> type) {
 
 		Parameter parameter = parameters.next();
 		Class<?> typeToUse = ClassUtils.isAssignable(type, parameter.getType()) ? parameter.getType() : type;
-		return (ParameterMetadata<? extends T>) next(part, typeToUse, parameter);
+		return next(part, typeToUse, parameter);
 	}
 
 	/**
-	 * Builds a new {@link ParameterMetadata} for the given type and name.
+	 * Builds a new {@link PartTreeParameterBinding} for the given type and name.
 	 *
 	 * @param <T> type parameter for the returned {@link ParameterMetadata}.
 	 * @param part must not be {@literal null}.
@@ -156,7 +165,7 @@ public <T> ParameterMetadata<? extends T> next(Part part, Class<T> type) {
 	 * @param parameter providing the name for the returned {@link ParameterMetadata}.
 	 * @return a new {@link ParameterMetadata} for the given type and name.
 	 */
-	private <T> ParameterMetadata<T> next(Part part, Class<T> type, Parameter parameter) {
+	private <T> PartTreeParameterBinding next(Part part, Class<T> type, Parameter parameter) {
 
 		Assert.notNull(type, "Type must not be null");
 
@@ -166,37 +175,67 @@ private <T> ParameterMetadata<T> next(Part part, Class<T> type, Parameter parame
 		@SuppressWarnings("unchecked")
 		Class<T> reifiedType = Expression.class.equals(type) ? (Class<T>) Object.class : type;
 
-		Supplier<String> name = () -> parameter.getName()
-				.orElseThrow(() -> new IllegalArgumentException("o_O Parameter needs to be named"));
+		Object value = bindableParameterValues == null ? ParameterMetadata.PLACEHOLDER : bindableParameterValues.next();
+
+		int currentPosition = ++position;
 
-		ParameterExpression<T> expression = parameter.isExplicitlyNamed() //
-				? builder.parameter(reifiedType, name.get()) //
-				: builder.parameter(reifiedType);
+		BindingIdentifier bindingIdentifier = parameter.getName().map(it -> BindingIdentifier.of(it, currentPosition))
+				.orElseGet(() -> BindingIdentifier.of(currentPosition));
 
-		Object value = bindableParameterValues == null ? ParameterMetadata.PLACEHOLDER : bindableParameterValues.next();
+		/* identifier refers to bindable parameters, not _all_ parameters index */
+		MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(bindingIdentifier);
+		PartTreeParameterBinding binding = new PartTreeParameterBinding(bindingIdentifier, methodParameter, reifiedType,
+				part, value, templates, escape);
 
-		ParameterMetadata<T> metadata = new ParameterMetadata<>(expression, part, value, escape);
-		expressions.add(metadata);
+		bindings.add(binding);
 
-		return metadata;
+		return binding;
 	}
 
 	EscapeCharacter getEscape() {
 		return escape;
 	}
 
+	/**
+	 * Builds a new synthetic {@link ParameterBinding} for the given value.
+	 *
+	 * @param nameHint
+	 * @param value
+	 * @param source
+	 * @return a new {@link ParameterBinding} for the given value and source.
+	 */
+	public ParameterBinding nextSynthetic(String nameHint, Object value, Object source) {
+
+		int currentPosition = ++position;
+		String bindingName = nameHint;
+
+		if (!syntheticParameterNames.add(bindingName)) {
+
+			bindingName = bindingName + "_" + currentPosition;
+			syntheticParameterNames.add(bindingName);
+		}
+
+		return new ParameterBinding(BindingIdentifier.of(bindingName, currentPosition),
+				ParameterOrigin.synthetic(value, source));
+	}
+
+	public JpaParameters getParameters() {
+		return this.jpaParameters;
+	}
+
 	/**
 	 * @author Oliver Gierke
 	 * @author Thomas Darimont
 	 * @author Andrey Kovalev
-	 * @param <T>
 	 */
-	public static class ParameterMetadata<T> {
+	public static class ParameterMetadata {
 
 		static final Object PLACEHOLDER = new Object();
 
+		private final Class<?> parameterType;
 		private final Type type;
-		private final ParameterExpression<T> expression;
+		private final int position;
+		private final JpqlQueryTemplates templates;
 		private final EscapeCharacter escape;
 		private final boolean ignoreCase;
 		private final boolean noWildcards;
@@ -204,10 +243,12 @@ public static class ParameterMetadata<T> {
 		/**
 		 * Creates a new {@link ParameterMetadata}.
 		 */
-		public ParameterMetadata(ParameterExpression<T> expression, Part part, @Nullable Object value,
-				EscapeCharacter escape) {
+		public ParameterMetadata(Class<?> parameterType, Part part, @Nullable Object value, EscapeCharacter escape,
+				int position, JpqlQueryTemplates templates) {
 
-			this.expression = expression;
+			this.parameterType = parameterType;
+			this.position = position;
+			this.templates = templates;
 			this.type = value == null
 					&& (Type.SIMPLE_PROPERTY.equals(part.getType()) || Type.NEGATING_SIMPLE_PROPERTY.equals(part.getType()))
 							? Type.IS_NULL
@@ -217,13 +258,12 @@ public ParameterMetadata(ParameterExpression<T> expression, Part part, @Nullable
 			this.escape = escape;
 		}
 
-		/**
-		 * Returns the {@link ParameterExpression}.
-		 *
-		 * @return the expression
-		 */
-		public ParameterExpression<T> getExpression() {
-			return expression;
+		public int getPosition() {
+			return position;
+		}
+
+		public Class<?> getParameterType() {
+			return parameterType;
 		}
 
 		/**
@@ -238,30 +278,24 @@ public boolean isIsNullParameter() {
 		 *
 		 * @param value can be {@literal null}.
 		 */
-		@Nullable
-		public Object prepare(@Nullable Object value) {
+		public @Nullable Object prepare(@Nullable Object value) {
 
-			if (value == null || expression.getJavaType() == null) {
+			if (value == null || parameterType == null) {
 				return value;
 			}
 
-			if (String.class.equals(expression.getJavaType()) && !noWildcards) {
-
-				switch (type) {
-					case STARTING_WITH:
-						return String.format("%s%%", escape.escape(value.toString()));
-					case ENDING_WITH:
-						return String.format("%%%s", escape.escape(value.toString()));
-					case CONTAINING:
-					case NOT_CONTAINING:
-						return String.format("%%%s%%", escape.escape(value.toString()));
-					default:
-						return value;
-				}
+			if (String.class.equals(parameterType) && !noWildcards) {
+
+                return switch (type) {
+                    case STARTING_WITH -> String.format("%s%%", escape.escape(value.toString()));
+                    case ENDING_WITH -> String.format("%%%s", escape.escape(value.toString()));
+                    case CONTAINING, NOT_CONTAINING -> String.format("%%%s%%", escape.escape(value.toString()));
+                    default -> value;
+                };
 			}
 
-			return Collection.class.isAssignableFrom(expression.getJavaType()) //
-					? upperIfIgnoreCase(ignoreCase, toCollection(value)) //
+			return Collection.class.isAssignableFrom(parameterType) //
+					? potentiallyIgnoreCase(ignoreCase, toCollection(value)) //
 					: value;
 		}
 
@@ -273,8 +307,7 @@ public Object prepare(@Nullable Object value) {
 		 * @param value the value to be converted to a {@link Collection}.
 		 * @return the object itself as a {@link Collection} or a {@link Collection} constructed from the value.
 		 */
-		@Nullable
-		private static Collection<?> toCollection(@Nullable Object value) {
+		private static @Nullable Collection<?> toCollection(@Nullable Object value) {
 
 			if (value == null) {
 				return null;
@@ -293,9 +326,8 @@ private static Collection<?> toCollection(@Nullable Object value) {
 			return Collections.singleton(value);
 		}
 
-		@Nullable
 		@SuppressWarnings("unchecked")
-		private static Collection<?> upperIfIgnoreCase(boolean ignoreCase, @Nullable Collection<?> collection) {
+		private @Nullable Collection<?> potentiallyIgnoreCase(boolean ignoreCase, @Nullable Collection<?> collection) {
 
 			if (!ignoreCase || CollectionUtils.isEmpty(collection)) {
 				return collection;
@@ -304,8 +336,9 @@ private static Collection<?> upperIfIgnoreCase(boolean ignoreCase, @Nullable Col
 			return ((Collection<String>) collection).stream() //
 					.map(it -> it == null //
 							? null //
-							: it.toUpperCase()) //
+							: templates.ignoreCase(it)) //
 					.collect(Collectors.toList());
 		}
+
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParametrizedQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParametrizedQuery.java
new file mode 100644
index 0000000000..4736e091fc
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParametrizedQuery.java
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2018-2024 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.springframework.data.jpa.repository.query;
+
+import java.util.List;
+
+/**
+ * A parsed and structured representation of a query providing introspection details about parameter bindings.
+ * <p>
+ * Structured queries can be either created from {@link EntityQuery} introspection or through
+ * {@link EntityQuery#deriveCountQuery(String) count query derivation}.
+ *
+ * @author Jens Schauder
+ * @author Diego Krupitza
+ * @since 4.0
+ * @see EntityQuery
+ * @see EntityQuery#create(DeclaredQuery, QueryEnhancerSelector)
+ * @see TemplatedQuery#create(String, JpaQueryMethod, JpaQueryConfiguration)
+ */
+public interface ParametrizedQuery extends QueryProvider {
+
+	/**
+	 * @return whether the underlying query has at least one parameter.
+	 */
+	boolean hasParameterBindings();
+
+	/**
+	 * Returns whether the query uses JDBC style parameters, i.e. parameters denoted by a simple ? without any index or
+	 * name.
+	 *
+	 * @return Whether the query uses JDBC style parameters.
+	 * @since 2.0.6
+	 */
+	boolean usesJdbcStyleParameters();
+
+	/**
+	 * @return whether the underlying query has at least one named parameter.
+	 */
+	boolean hasNamedParameter();
+
+	/**
+	 * @return the registered {@link ParameterBinding}s.
+	 */
+	List<ParameterBinding> getParameterBindings();
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java
index a1246ac056..bf254c46ba 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java
@@ -18,12 +18,15 @@
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.PersistenceUnitUtil;
 import jakarta.persistence.Query;
+import jakarta.persistence.Tuple;
 import jakarta.persistence.TypedQuery;
-import jakarta.persistence.criteria.CriteriaBuilder;
 import jakarta.persistence.criteria.CriteriaQuery;
 
 import java.util.List;
-import java.util.concurrent.locks.ReentrantLock;
+
+import org.jspecify.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 import org.springframework.data.domain.KeysetScrollPosition;
 import org.springframework.data.domain.OffsetScrollPosition;
@@ -33,15 +36,15 @@
 import org.springframework.data.jpa.repository.query.JpaQueryExecution.DeleteExecution;
 import org.springframework.data.jpa.repository.query.JpaQueryExecution.ExistsExecution;
 import org.springframework.data.jpa.repository.query.JpaQueryExecution.ScrollExecution;
-import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata;
 import org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformation;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
 import org.springframework.data.repository.query.ResultProcessor;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.repository.query.parser.Part;
 import org.springframework.data.repository.query.parser.Part.Type;
 import org.springframework.data.repository.query.parser.PartTree;
 import org.springframework.data.util.Streamable;
-import org.springframework.lang.Nullable;
+import org.springframework.util.Assert;
 
 /**
  * A {@link AbstractJpaQuery} implementation based on a {@link PartTree}.
@@ -55,10 +58,13 @@
  */
 public class PartTreeJpaQuery extends AbstractJpaQuery {
 
+	private static final Logger log = LoggerFactory.getLogger(PartTreeJpaQuery.class);
+	private final JpqlQueryTemplates templates = JpqlQueryTemplates.UPPER;
+
 	private final PartTree tree;
 	private final JpaParameters parameters;
 
-	private final QueryPreparer query;
+	private final QueryPreparer queryPreparer;
 	private final QueryPreparer countQuery;
 	private final EntityManager em;
 	private final EscapeCharacter escape;
@@ -93,15 +99,12 @@ public class PartTreeJpaQuery extends AbstractJpaQuery {
 		PersistenceUnitUtil persistenceUnitUtil = em.getEntityManagerFactory().getPersistenceUnitUtil();
 		this.entityInformation = new JpaMetamodelEntityInformation<>(domainClass, em.getMetamodel(), persistenceUnitUtil);
 
-		boolean recreationRequired = parameters.hasDynamicProjection() || parameters.potentiallySortsDynamically()
-				|| method.isScrollQuery();
-
 		try {
 
 			this.tree = new PartTree(method.getName(), domainClass);
 			validate(tree, parameters, method.toString());
-			this.countQuery = new CountQueryPreparer(recreationRequired);
-			this.query = tree.isCountProjection() ? countQuery : new QueryPreparer(recreationRequired);
+			this.countQuery = new CountQueryPreparer();
+			this.queryPreparer = tree.isCountProjection() ? countQuery : new QueryPreparer();
 
 		} catch (Exception o_O) {
 			throw new IllegalArgumentException(
@@ -109,9 +112,14 @@ public class PartTreeJpaQuery extends AbstractJpaQuery {
 		}
 	}
 
+	@Override
+	public boolean hasDeclaredCountQuery() {
+		return false;
+	}
+
 	@Override
 	public Query doCreateQuery(JpaParametersParameterAccessor accessor) {
-		return query.createQuery(accessor);
+		return queryPreparer.createQuery(accessor);
 	}
 
 	@Override
@@ -208,57 +216,42 @@ private static boolean expectsCollection(Type type) {
 	 */
 	private class QueryPreparer {
 
-		private final @Nullable CriteriaQuery<?> cachedCriteriaQuery;
-		private final ReentrantLock lock = new ReentrantLock();
-		private final @Nullable ParameterBinder cachedParameterBinder;
-		private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache();
-
-		QueryPreparer(boolean recreateQueries) {
-
-			JpaQueryCreator creator = createCreator(null);
-
-			if (recreateQueries) {
-				this.cachedCriteriaQuery = null;
-				this.cachedParameterBinder = null;
-			} else {
-				this.cachedCriteriaQuery = creator.createQuery();
-				this.cachedParameterBinder = getBinder(creator.getParameterExpressions());
-			}
-		}
+		private final PartTreeQueryCache cache = new PartTreeQueryCache();
 
 		/**
 		 * Creates a new {@link Query} for the given parameter values.
 		 */
 		public Query createQuery(JpaParametersParameterAccessor accessor) {
 
-			CriteriaQuery<?> criteriaQuery = cachedCriteriaQuery;
-			ParameterBinder parameterBinder = cachedParameterBinder;
+			Sort sort = getDynamicSort(accessor);
+			JpqlQueryCreator creator = createCreator(sort, accessor);
+			String jpql = creator.createQuery(sort);
+			Query query;
 
-			if (cachedCriteriaQuery == null || accessor.hasBindableNullValue()) {
-				JpaQueryCreator creator = createCreator(accessor);
-				criteriaQuery = creator.createQuery(getDynamicSort(accessor));
-				List<ParameterMetadata<?>> expressions = creator.getParameterExpressions();
-				parameterBinder = getBinder(expressions);
+			if (log.isDebugEnabled()) {
+				log.debug(String.format("%s: Derived query for query method [%s]: '%s'", getClass().getSimpleName(),
+						getQueryMethod(), jpql));
 			}
 
-			if (parameterBinder == null) {
-				throw new IllegalStateException("ParameterBinder is null");
+			try {
+				query = creator.useTupleQuery() ? em.createQuery(jpql, Tuple.class) : em.createQuery(jpql);
+			} catch (Exception e) {
+				throw new BadJpqlGrammarException(e.getMessage(), jpql, e);
 			}
 
-			TypedQuery<?> query = createQuery(criteriaQuery);
+			ParameterBinder binder = creator.getBinder();
 
 			ScrollPosition scrollPosition = accessor.getParameters().hasScrollPositionParameter()
 					? accessor.getScrollPosition()
 					: null;
-			return restrictMaxResultsIfNecessary(invokeBinding(parameterBinder, query, accessor, this.metadataCache),
-					scrollPosition);
+			return restrictMaxResultsIfNecessary(invokeBinding(binder, query, accessor), scrollPosition);
 		}
 
 		/**
 		 * Restricts the max results of the given {@link Query} if the current {@code tree} marks this {@code query} as
 		 * limited.
 		 */
-		@SuppressWarnings("ConstantConditions")
+		@SuppressWarnings({ "ConstantConditions", "NullAway" })
 		private Query restrictMaxResultsIfNecessary(Query query, @Nullable ScrollPosition scrollPosition) {
 
 			if (scrollPosition instanceof OffsetScrollPosition offset && !offset.isInitial()) {
@@ -289,65 +282,82 @@ private Query restrictMaxResultsIfNecessary(Query query, @Nullable ScrollPositio
 			return query;
 		}
 
-		/**
-		 * Checks whether we are working with a cached {@link CriteriaQuery} and synchronizes the creation of a
-		 * {@link TypedQuery} instance from it. This is due to non-thread-safety in the {@link CriteriaQuery} implementation
-		 * of some persistence providers (i.e. Hibernate in this case), see DATAJPA-396.
-		 *
-		 * @param criteriaQuery must not be {@literal null}.
-		 */
-		private TypedQuery<?> createQuery(CriteriaQuery<?> criteriaQuery) {
-
-			if (this.cachedCriteriaQuery != null) {
-				lock.lock();
-				try {
-					return getEntityManager().createQuery(criteriaQuery);
-				} finally {
-					lock.unlock();
-				}
+		protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) {
+
+			JpqlQueryCreator jpqlQueryCreator = cache.get(sort, accessor); // this caching thingy is broken due to IS NULL
+																																			// rendering for
+			if (jpqlQueryCreator != null) {
+				return jpqlQueryCreator;
 			}
 
-			return getEntityManager().createQuery(criteriaQuery);
+			EntityManager entityManager = getEntityManager();
+			ResultProcessor processor = getQueryMethod().getResultProcessor();
+
+			ParameterMetadataProvider provider = new ParameterMetadataProvider(accessor, escape, templates);
+			ReturnedType returnedType = processor.withDynamicProjection(accessor).getReturnedType();
+
+			if (accessor.getScrollPosition() instanceof KeysetScrollPosition keyset) {
+				return new JpaKeysetScrollQueryCreator(tree, returnedType, provider, templates, entityInformation, keyset,
+						entityManager);
+			}
+
+			JpqlQueryCreator creator = new CacheableJpqlQueryCreator(sort,
+					new JpaQueryCreator(tree, returnedType, provider, templates, em));
+
+			if (accessor.getParameters().hasDynamicProjection()) {
+				return creator;
+			}
+
+			cache.put(sort, accessor, creator);
+
+			return creator;
 		}
 
-		protected JpaQueryCreator createCreator(@Nullable JpaParametersParameterAccessor accessor) {
+		static class CacheableJpqlQueryCreator implements JpqlQueryCreator {
 
-			EntityManager entityManager = getEntityManager();
+			private final Sort expectedSort;
+			private final String query;
+			private final boolean useTupleQuery;
+			private final List<ParameterBinding> parameterBindings;
+			private final ParameterBinder binder;
 
-			CriteriaBuilder builder = entityManager.getCriteriaBuilder();
-			ResultProcessor processor = getQueryMethod().getResultProcessor();
+			public CacheableJpqlQueryCreator(Sort expectedSort, JpqlQueryCreator delegate) {
 
-			ParameterMetadataProvider provider;
-			ReturnedType returnedType;
+				this.expectedSort = expectedSort;
+				this.query = delegate.createQuery(expectedSort);
+				this.useTupleQuery = delegate.useTupleQuery();
+				this.parameterBindings = delegate.getBindings();
+				this.binder = delegate.getBinder();
+			}
 
-			if (accessor != null) {
-				provider = new ParameterMetadataProvider(builder, accessor, escape);
-				returnedType = processor.withDynamicProjection(accessor).getReturnedType();
-			} else {
-				provider = new ParameterMetadataProvider(builder, parameters, escape);
-				returnedType = processor.getReturnedType();
+			@Override
+			public boolean useTupleQuery() {
+				return useTupleQuery;
 			}
 
-			if (accessor != null && accessor.getScrollPosition() instanceof KeysetScrollPosition keyset) {
-				return new JpaKeysetScrollQueryCreator(tree, returnedType, builder, provider, entityInformation, keyset);
+			@Override
+			public String createQuery(Sort sort) {
+
+				Assert.isTrue(sort.equals(expectedSort), "Expected sort does not match");
+				return query;
 			}
 
-			return new JpaQueryCreator(tree, returnedType, builder, provider);
+			@Override
+			public List<ParameterBinding> getBindings() {
+				return parameterBindings;
+			}
+
+			@Override
+			public ParameterBinder getBinder() {
+				return binder;
+			}
 		}
 
 		/**
 		 * Invokes parameter binding on the given {@link TypedQuery}.
 		 */
-		protected Query invokeBinding(ParameterBinder binder, TypedQuery<?> query, JpaParametersParameterAccessor accessor,
-				QueryParameterSetter.QueryMetadataCache metadataCache) {
-
-			QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata("query", query);
-
-			return binder.bindAndPrepare(query, metadata, accessor);
-		}
-
-		private ParameterBinder getBinder(List<ParameterMetadata<?>> expressions) {
-			return ParameterBinderFactory.createCriteriaBinder(parameters, expressions);
+		protected Query invokeBinding(ParameterBinder binder, Query query, JpaParametersParameterAccessor accessor) {
+			return binder.bindAndPrepare(query, accessor);
 		}
 
 		private Sort getDynamicSort(JpaParametersParameterAccessor accessor) {
@@ -366,37 +376,71 @@ private Sort getDynamicSort(JpaParametersParameterAccessor accessor) {
 	 */
 	private class CountQueryPreparer extends QueryPreparer {
 
-		CountQueryPreparer(boolean recreateQueries) {
-			super(recreateQueries);
-		}
+		private final PartTreeQueryCache cache = new PartTreeQueryCache();
 
 		@Override
-		protected JpaQueryCreator createCreator(@Nullable JpaParametersParameterAccessor accessor) {
+		protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) {
 
-			EntityManager entityManager = getEntityManager();
-			CriteriaBuilder builder = entityManager.getCriteriaBuilder();
+			JpqlQueryCreator cached = cache.get(Sort.unsorted(), accessor);
+			if (cached != null) {
+				return cached;
+			}
 
-			ParameterMetadataProvider provider;
+			ParameterMetadataProvider provider = new ParameterMetadataProvider(accessor, escape, templates);
+			JpaCountQueryCreator creator = new JpaCountQueryCreator(tree,
+					getQueryMethod().getResultProcessor().getReturnedType(), provider, templates, em);
 
-			if (accessor != null) {
-				provider = new ParameterMetadataProvider(builder, accessor, escape);
-			} else {
-				provider = new ParameterMetadataProvider(builder, parameters, escape);
+			if (!accessor.getParameters().hasDynamicProjection()) {
+				cached = new CacheableJpqlCountQueryCreator(creator);
+				cache.put(Sort.unsorted(), accessor, cached);
+				return cached;
 			}
 
-			return new JpaCountQueryCreator(tree, getQueryMethod().getResultProcessor().getReturnedType(), builder, provider);
+			return creator;
 		}
 
 		/**
 		 * Customizes binding by skipping the pagination.
 		 */
 		@Override
-		protected Query invokeBinding(ParameterBinder binder, TypedQuery<?> query, JpaParametersParameterAccessor accessor,
-				QueryParameterSetter.QueryMetadataCache metadataCache) {
+		protected Query invokeBinding(ParameterBinder binder, Query query, JpaParametersParameterAccessor accessor) {
+			return binder.bind(query, accessor);
+		}
+
+		static class CacheableJpqlCountQueryCreator implements JpqlQueryCreator {
+
+			private final String query;
+			private final boolean useTupleQuery;
+			private final List<ParameterBinding> parameterBindings;
+			private final ParameterBinder binder;
+
+			public CacheableJpqlCountQueryCreator(JpqlQueryCreator delegate) {
+
+				this.query = delegate.createQuery(Sort.unsorted());
+				this.useTupleQuery = delegate.useTupleQuery();
+				this.parameterBindings = delegate.getBindings();
+				this.binder = delegate.getBinder();
+			}
 
-			QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata("countquery", query);
+			@Override
+			public boolean useTupleQuery() {
+				return useTupleQuery;
+			}
+
+			@Override
+			public String createQuery(Sort sort) {
+				return query;
+			}
 
-			return binder.bind(query, metadata, accessor);
+			@Override
+			public List<ParameterBinding> getBindings() {
+				return parameterBindings;
+			}
+
+			@Override
+			public ParameterBinder getBinder() {
+				return binder;
+			}
 		}
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java
new file mode 100644
index 0000000000..707ee20518
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeQueryCache.java
@@ -0,0 +1,107 @@
+/*
+ * 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.springframework.data.jpa.repository.query;
+
+import java.util.BitSet;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+import org.springframework.data.domain.Sort;
+
+import org.jspecify.annotations.Nullable;
+import org.springframework.util.ObjectUtils;
+
+/**
+ * Cache for PartTree queries.
+ *
+ * @author Christoph Strobl
+ */
+class PartTreeQueryCache {
+
+	private final Map<CacheKey, JpqlQueryCreator> cache = Collections.synchronizedMap(new LinkedHashMap<>() {
+		@Override
+		protected boolean removeEldestEntry(Map.Entry<CacheKey, JpqlQueryCreator> eldest) {
+			return size() > 256;
+		}
+	});
+
+	@Nullable
+	JpqlQueryCreator get(Sort sort, JpaParametersParameterAccessor accessor) {
+		return cache.get(CacheKey.of(sort, accessor));
+	}
+
+	@Nullable
+	JpqlQueryCreator put(Sort sort, JpaParametersParameterAccessor accessor, JpqlQueryCreator creator) {
+		return cache.put(CacheKey.of(sort, accessor), creator);
+	}
+
+	static class CacheKey {
+
+		private final Sort sort;
+
+		/**
+		 * Bitset of null/non-null parameter values. A 0 bit means the parameter value is {@code null}, a 1 bit means the
+		 * parameter is not {@code null}.
+		 */
+		private final BitSet params;
+
+		public CacheKey(Sort sort, BitSet params) {
+			this.sort = sort;
+			this.params = params;
+		}
+
+		static CacheKey of(Sort sort, JpaParametersParameterAccessor accessor) {
+
+			Object[] values = accessor.getValues();
+
+			if (ObjectUtils.isEmpty(values)) {
+				return new CacheKey(sort, new BitSet());
+			}
+
+			return new CacheKey(sort, toNullableMap(values));
+		}
+
+		static BitSet toNullableMap(Object[] args) {
+
+			BitSet bitSet = new BitSet(args.length);
+			for (int i = 0; i < args.length; i++) {
+				bitSet.set(i, args[i] != null);
+			}
+
+			return bitSet;
+		}
+
+		@Override
+		public boolean equals(Object o) {
+			if (o == this) {
+				return true;
+			}
+			if (o == null || getClass() != o.getClass()) {
+				return false;
+			}
+			CacheKey cacheKey = (CacheKey) o;
+			return sort.equals(cacheKey.sort) && params.equals(cacheKey.params);
+		}
+
+		@Override
+		public int hashCode() {
+			return Objects.hash(sort, params);
+		}
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java
similarity index 65%
rename from spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java
rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java
index b36d7e728e..6f36ac80a3 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2013-2025 the original author or authors.
+ * Copyright 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.
@@ -19,6 +19,7 @@
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 import java.util.TreeSet;
@@ -29,16 +30,13 @@
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.expression.ValueExpression;
 import org.springframework.data.expression.ValueExpressionParser;
 import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier;
-import org.springframework.data.jpa.repository.query.ParameterBinding.InParameterBinding;
-import org.springframework.data.jpa.repository.query.ParameterBinding.LikeParameterBinding;
-import org.springframework.data.jpa.repository.query.ParameterBinding.MethodInvocationArgument;
-import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin;
 import org.springframework.data.repository.query.ValueExpressionQueryRewriter;
-import org.springframework.data.repository.query.parser.Part.Type;
-import org.springframework.lang.Nullable;
+import org.springframework.data.repository.query.parser.Part;
 import org.springframework.util.Assert;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
@@ -46,230 +44,126 @@
 import org.springframework.util.StringUtils;
 
 /**
- * Encapsulation of a JPA query String. Offers access to parameters as bindings. The internal query String is cleaned
- * from decorated parameters like {@literal %:lastname%} and the matching bindings take care of applying the decorations
- * in the {@link ParameterBinding#prepare(Object)} method. Note that this class also handles replacing SpEL expressions
- * with synthetic bind parameters.
+ * A pre-parsed query implementing {@link DeclaredQuery} providing information about parameter bindings.
+ * <p>
+ * Query-preprocessing transforms queries using Spring Data-specific syntax such as {@link TemplatedQuery query
+ * templating}, extended {@code LIKE} syntax and usage of {@link ValueExpression value expressions} into a syntax that
+ * is valid for JPA queries (JPQL and native).
+ * <p>
+ * Preprocessing consists of parsing and rewriting so that no extension elements interfere with downstream parsers.
+ * However, pre-processing is a lossy procedure because the resulting {@link #getQueryString() query string} only
+ * contains parameter binding markers and so the original query cannot be restored. Any query derivation must align its
+ * {@link ParameterBinding parameter bindings} to ensure the derived query uses the same binding semantics instead of
+ * plain parameters. See {@link ParameterBinding#isCompatibleWith(ParameterBinding)} for further reference.
  *
- * @author Oliver Gierke
- * @author Thomas Darimont
- * @author Oliver Wehrens
+ * @author Christoph Strobl
  * @author Mark Paluch
- * @author Jens Schauder
- * @author Diego Krupitza
- * @author Greg Turnquist
- * @author Yuriy Tsarkov
+ * @since 4.0
  */
-class StringQuery implements DeclaredQuery {
+public final class PreprocessedQuery implements DeclaredQuery {
 
-	private final String query;
+	private final DeclaredQuery source;
 	private final List<ParameterBinding> bindings;
-	private final boolean containsPageableInSpel;
 	private final boolean usesJdbcStyleParameters;
-	private final boolean isNative;
-	private final QueryEnhancer queryEnhancer;
-	private final boolean hasNamedParameters;
-
-	/**
-	 * Creates a new {@link StringQuery} from the given JPQL query.
-	 *
-	 * @param query must not be {@literal null} or empty.
-	 */
-	public StringQuery(String query, boolean isNative) {
-		this(query, isNative, it -> {});
+	private final boolean containsPageableInSpel;
+	private final boolean hasNamedBindings;
+
+	private PreprocessedQuery(DeclaredQuery query, List<ParameterBinding> bindings, boolean usesJdbcStyleParameters,
+			boolean containsPageableInSpel) {
+		this.source = query;
+		this.bindings = bindings;
+		this.usesJdbcStyleParameters = usesJdbcStyleParameters;
+		this.containsPageableInSpel = containsPageableInSpel;
+		this.hasNamedBindings = containsNamedParameter(bindings);
 	}
 
-	/**
-	 * Creates a new {@link StringQuery} from the given JPQL query.
-	 *
-	 * @param query must not be {@literal null} or empty.
-	 */
-	private StringQuery(String query, boolean isNative, Consumer<List<ParameterBinding>> parameterPostProcessor) {
-
-		Assert.hasText(query, "Query must not be null or empty");
-
-		this.isNative = isNative;
-		this.bindings = new ArrayList<>();
-		this.containsPageableInSpel = query.contains("#pageable");
-
-		Metadata queryMeta = new Metadata();
-		this.query = ParameterBindingParser.INSTANCE.parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(query,
-				this.bindings, queryMeta);
+	private static boolean containsNamedParameter(List<ParameterBinding> bindings) {
 
-		this.usesJdbcStyleParameters = queryMeta.usesJdbcStyleParameters;
-		this.queryEnhancer = QueryEnhancerFactory.forQuery(this);
-
-		parameterPostProcessor.accept(this.bindings);
-
-		boolean hasNamedParameters = false;
-		for (ParameterBinding parameterBinding : getParameterBindings()) {
-			if (parameterBinding.getIdentifier().hasName() && parameterBinding.getOrigin().isMethodArgument()) {
-				hasNamedParameters = true;
-				break;
+		for (ParameterBinding parameterBinding : bindings) {
+			if (parameterBinding.getIdentifier().hasName() && parameterBinding.getOrigin()
+					.isMethodArgument()) {
+				return true;
 			}
 		}
-
-		this.hasNamedParameters = hasNamedParameters;
+		return false;
 	}
 
 	/**
-	 * Returns whether we have found some like bindings.
+	 * Parse a {@link DeclaredQuery query} into its parametrized form by identifying anonymous, named, indexed and SpEL
+	 * parameters. Query parsing applies special treatment to {@code IN} and {@code LIKE} parameter bindings.
+	 *
+	 * @param declaredQuery the source query to parse.
+	 * @return a parsed {@link PreprocessedQuery}.
 	 */
-	boolean hasParameterBindings() {
-		return !bindings.isEmpty();
-	}
-
-	String getProjection() {
-		return this.queryEnhancer.getProjection();
-	}
-
-	@Override
-	public List<ParameterBinding> getParameterBindings() {
-		return bindings;
-	}
-
-	@Override
-	public DeclaredQuery deriveCountQuery(@Nullable String countQueryProjection) {
-
-		// need to copy expression bindings from the declared to the derived query as JPQL query derivation only sees
-		// JPA parameter markers and not the original expressions anymore.
-
-		return new StringQuery(this.queryEnhancer.createCountQueryFor(countQueryProjection), //
-				this.isNative, derivedBindings -> {
-
-					// need to copy expression bindings from the declared to the derived query as JPQL query derivation only sees
-					// JPA
-					// parameter markers and not the original expressions anymore.
-					if (this.hasParameterBindings() && !this.getParameterBindings().equals(derivedBindings)) {
-
-						for (ParameterBinding binding : bindings) {
-
-							Predicate<ParameterBinding> identifier = binding::bindsTo;
-				Predicate<ParameterBinding> notCompatible = Predicate.not(binding::isCompatibleWith);
-
-				// replace incompatible bindings
-				if ( derivedBindings.removeIf(
-									it -> identifier.test(it) && notCompatible.test(it))) {
-								derivedBindings.add(binding);
-							}
-						}
-					}
+	public static PreprocessedQuery parse(DeclaredQuery declaredQuery) {
+		return ParameterBindingParser.INSTANCE.parse(declaredQuery.getQueryString(), declaredQuery::rewrite,
+				parameterBindings -> {
 				});
 	}
 
-	@Override
-	public boolean usesJdbcStyleParameters() {
-		return usesJdbcStyleParameters;
-	}
-
 	@Override
 	public String getQueryString() {
-		return query;
+		return source.getQueryString();
 	}
 
 	@Override
-	@Nullable
-	public String getAlias() {
-		return queryEnhancer.detectAlias();
+	public boolean isNative() {
+		return source.isNative();
 	}
 
-	@Override
-	public boolean hasConstructorExpression() {
-		return queryEnhancer.hasConstructorExpression();
+	boolean hasBindings() {
+		return !bindings.isEmpty();
 	}
 
-	@Override
-	public boolean isDefaultProjection() {
-		return getProjection().equalsIgnoreCase(getAlias());
+	boolean hasNamedBindings() {
+		return this.hasNamedBindings;
 	}
 
-	@Override
-	public boolean hasNamedParameter() {
-		return hasNamedParameters;
+	boolean containsPageableInSpel() {
+		return containsPageableInSpel;
 	}
 
-	@Override
-	public boolean usesPaging() {
-		return containsPageableInSpel;
+	boolean usesJdbcStyleParameters() {
+		return usesJdbcStyleParameters;
 	}
 
-	@Override
-	public boolean isNativeQuery() {
-		return isNative;
+	public List<ParameterBinding> getBindings() {
+		return Collections.unmodifiableList(bindings);
 	}
 
 	/**
-	 * Value object to track and allocate used parameter index labels in a query.
+	 * Derive a query (typically a count query) from the given query string. We need to copy expression bindings from the
+	 * declared to the derived query as JPQL query derivation only sees JPA parameter markers and not the original
+	 * expressions anymore.
+	 *
+	 * @return
 	 */
-	static class IndexedParameterLabels {
-
-		private final TreeSet<Integer> usedLabels;
-		private final boolean sequential;
-
-		public IndexedParameterLabels(Set<Integer> usedLabels) {
-
-			this.usedLabels = usedLabels instanceof TreeSet<Integer> ts ? ts : new TreeSet<Integer>(usedLabels);
-			this.sequential = isSequential(usedLabels);
-		}
-
-		private static boolean isSequential(Set<Integer> usedLabels) {
-
-			for (int i = 0; i < usedLabels.size(); i++) {
-
-				if (usedLabels.contains(i + 1)) {
-					continue;
-				}
-
-				return false;
-			}
-
-			return true;
-		}
-
-		/**
-		 * Allocate the next index label (1-based).
-		 *
-		 * @return the next index label.
-		 */
-		public int allocate() {
-
-			if (sequential) {
-				int index = usedLabels.size() + 1;
-				usedLabels.add(index);
-
-				return index;
-			}
-
-			int attempts = usedLabels.last() + 1;
-			int index = attemptAllocate(attempts);
-
-			if (index == -1) {
-				throw new IllegalStateException(
-						"Unable to allocate a unique parameter label. All possible labels have been used.");
-			}
+	@Override
+	public PreprocessedQuery rewrite(String newQueryString) {
 
-			usedLabels.add(index);
+		return ParameterBindingParser.INSTANCE.parse(newQueryString, source::rewrite, derivedBindings -> {
 
-			return index;
-		}
+			// need to copy expression bindings from the declared to the derived query as JPQL query derivation only sees
+			// JPA parameter markers and not the original expressions anymore.
+			if (this.hasBindings() && !this.bindings.equals(derivedBindings)) {
 
-		private int attemptAllocate(int attempts) {
+				for (ParameterBinding binding : bindings) {
 
-			for (int i = 0; i < attempts; i++) {
+					Predicate<ParameterBinding> identifier = binding::bindsTo;
+					Predicate<ParameterBinding> notCompatible = Predicate.not(binding::isCompatibleWith);
 
-				if (usedLabels.contains(i + 1)) {
-					continue;
+					// replace incompatible bindings
+					if (derivedBindings.removeIf(it -> identifier.test(it) && notCompatible.test(it))) {
+						derivedBindings.add(binding);
+					}
 				}
-
-				return i + 1;
 			}
+		});
+	}
 
-			return -1;
-		}
-
-		public boolean hasLabels() {
-			return !usedLabels.isEmpty();
-		}
+	@Override
+	public String toString() {
+		return "ParametrizedQuery[" + source + ", " + bindings + ']';
 	}
 
 	/**
@@ -293,7 +187,7 @@ enum ParameterBindingParser {
 		private static final Pattern NAMED_STYLE_PARAM = Pattern.compile("(?!\\\\):\\w+"); // no \ and :[text]
 
 		private static final String MESSAGE = "Already found parameter binding with same index / parameter name but differing binding type; "
-				+ "Already have: %s, found %s; If you bind a parameter multiple times make sure they use the same binding";
+											  + "Already have: %s, found %s; If you bind a parameter multiple times make sure they use the same binding";
 		private static final int INDEXED_PARAMETER_GROUP = 4;
 		private static final int NAMED_PARAMETER_GROUP = 6;
 		private static final int COMPARISION_TYPE_GROUP = 1;
@@ -331,12 +225,16 @@ enum ParameterBindingParser {
 		 * Parses {@link ParameterBinding} instances from the given query and adds them to the registered bindings. Returns
 		 * the cleaned up query.
 		 */
-		String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String query, List<ParameterBinding> bindings,
-				Metadata queryMeta) {
+		PreprocessedQuery parse(String query, Function<String, DeclaredQuery> declaredQueryFactory,
+				Consumer<List<ParameterBinding>> parameterBindingPostProcessor) {
 
 			IndexedParameterLabels parameterLabels = new IndexedParameterLabels(findParameterIndices(query));
 			boolean parametersShouldBeAccessedByIndex = parameterLabels.hasLabels();
 
+			List<ParameterBinding> bindings = new ArrayList<>();
+			boolean jdbcStyle = false;
+			boolean containsPageableInSpel = query.contains("#pageable");
+
 			/*
 			 * Prefer indexed access over named parameters if only SpEL Expression parameters are present.
 			 */
@@ -367,14 +265,15 @@ String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String que
 
 				String match = matcher.group(0);
 				if (JDBC_STYLE_PARAM.matcher(match).find()) {
-					queryMeta.usesJdbcStyleParameters = true;
+					jdbcStyle = true;
 				}
 
-				if (NUMBERED_STYLE_PARAM.matcher(match).find() || NAMED_STYLE_PARAM.matcher(match).find()) {
+				if (NUMBERED_STYLE_PARAM.matcher(match)
+							.find() || NAMED_STYLE_PARAM.matcher(match).find()) {
 					usesJpaStyleParameters = true;
 				}
 
-				if (usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) {
+				if (usesJpaStyleParameters && jdbcStyle) {
 					throw new IllegalArgumentException("Mixing of ? parameters and other forms like ?1 is not supported");
 				}
 
@@ -390,60 +289,64 @@ String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String que
 					parameterIndex = parameterLabels.allocate();
 				}
 
-				BindingIdentifier queryParameter;
+				ParameterBinding.BindingIdentifier queryParameter;
 				if (parameterIndex != null) {
-					queryParameter = BindingIdentifier.of(parameterIndex);
-				} else {
-					queryParameter = BindingIdentifier.of(parameterName);
+					queryParameter = ParameterBinding.BindingIdentifier.of(parameterIndex);
 				}
-				ParameterOrigin origin = ObjectUtils.isEmpty(expression)
-						? ParameterOrigin.ofParameter(parameterName, parameterIndex)
-						: ParameterOrigin.ofExpression(expression);
-
-				BindingIdentifier targetBinding = queryParameter;
-				Function<BindingIdentifier, ParameterBinding> bindingFactory;
-				switch (ParameterBindingType.of(typeSource)) {
-
-					case LIKE:
-
-						Type likeType = LikeParameterBinding.getLikeTypeFrom(matcher.group(2));
-						bindingFactory = (identifier) -> new LikeParameterBinding(identifier, origin, likeType);
-						break;
+				else if (parameterName != null) {
+					queryParameter = ParameterBinding.BindingIdentifier.of(parameterName);
+				}
+				else {
+					throw new IllegalStateException("No bindable expression found");
+				}
+				ParameterBinding.ParameterOrigin origin = ObjectUtils.isEmpty(expression)
+						? ParameterBinding.ParameterOrigin.ofParameter(parameterName, parameterIndex)
+						: ParameterBinding.ParameterOrigin.ofExpression(expression);
 
-					case IN:
-						bindingFactory = (identifier) -> new InParameterBinding(identifier, origin);
-						break;
+				ParameterBinding.BindingIdentifier targetBinding = queryParameter;
+				Function<ParameterBinding.BindingIdentifier, ParameterBinding> bindingFactory = switch (ParameterBindingType
+						.of(typeSource)) {
+					case LIKE -> {
 
-					case AS_IS: // fall-through we don't need a special parameter queryParameter for the given parameter.
-					default:
-						bindingFactory = (identifier) -> new ParameterBinding(identifier, origin);
-				}
+						Part.Type likeType = ParameterBinding.LikeParameterBinding.getLikeTypeFrom(matcher.group(2));
+						yield (identifier) -> new ParameterBinding.LikeParameterBinding(identifier, origin, likeType);
+					}
+					case IN ->
+							(identifier) -> new ParameterBinding.InParameterBinding(identifier, origin); // fall-through we
+					// don't need a special
+					// parameter queryParameter for the
+					// given parameter.
+					default -> (identifier) -> new ParameterBinding(identifier, origin);
+				};
 
 				if (origin.isExpression()) {
 					parameterBindings.register(bindingFactory.apply(queryParameter));
-				} else {
+				}
+				else {
 					targetBinding = parameterBindings.register(queryParameter, origin, bindingFactory, parameterLabels);
 				}
 
 				replacement = targetBinding.hasName() ? ":" + targetBinding.getName()
-						: ((!usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) ? "?"
-								: "?" + targetBinding.getPosition());
+						: ((!usesJpaStyleParameters && jdbcStyle) ? "?" : "?" + targetBinding.getPosition());
 				String result;
 				String substring = matcher.group(2);
 
 				int index = resultingQuery.indexOf(substring, currentIndex);
 				if (index < 0) {
 					result = resultingQuery;
-				} else {
+				}
+				else {
 					currentIndex = index + replacement.length();
 					result = resultingQuery.substring(0, index) + replacement
-							+ resultingQuery.substring(index + substring.length());
+							 + resultingQuery.substring(index + substring.length());
 				}
 
 				resultingQuery = result;
 			}
 
-			return resultingQuery;
+			parameterBindingPostProcessor.accept(bindings);
+			return new PreprocessedQuery(declaredQueryFactory.apply(resultingQuery), bindings, jdbcStyle,
+					containsPageableInSpel);
 		}
 
 		private static ValueExpressionQueryRewriter.ParsedQuery createSpelExtractor(String queryWithSpel,
@@ -466,8 +369,7 @@ private static ValueExpressionQueryRewriter.ParsedQuery createSpelExtractor(Stri
 			return rewriter.parse(queryWithSpel);
 		}
 
-		@Nullable
-		private static Integer getParameterIndex(@Nullable String parameterIndexString) {
+		private static @Nullable Integer getParameterIndex(@Nullable String parameterIndexString) {
 
 			if (parameterIndexString == null || parameterIndexString.isEmpty()) {
 				return null;
@@ -527,8 +429,7 @@ private enum ParameterBindingType {
 			 *
 			 * @return the keyword
 			 */
-			@Nullable
-			public String getKeyword() {
+			public @Nullable String getKeyword() {
 				return keyword;
 			}
 
@@ -553,18 +454,15 @@ static ParameterBindingType of(String typeSource) {
 		}
 	}
 
-	static class Metadata {
-		private boolean usesJdbcStyleParameters = false;
-	}
-
 	/**
 	 * Utility to create unique parameter bindings for LIKE that refer to the same underlying method parameter but are
-	 * bound to potentially unique query parameters for {@link LikeParameterBinding#prepare(Object) LIKE rewrite}.
+	 * bound to potentially unique query parameters for {@link ParameterBinding.LikeParameterBinding#prepare(Object) LIKE
+	 * rewrite}.
 	 *
 	 * @author Mark Paluch
 	 * @since 3.1.2
 	 */
-	static class ParameterBindings {
+	private static class ParameterBindings {
 
 		private final MultiValueMap<BindingIdentifier, ParameterBinding> methodArgumentToLikeBindings = new LinkedMultiValueMap<>();
 
@@ -580,21 +478,22 @@ public ParameterBindings(List<ParameterBinding> bindings, Consumer<ParameterBind
 		}
 
 		/**
-		 * Return whether the identifier is already bound.
-		 *
 		 * @param identifier
-		 * @return
+		 * @return whether the identifier is already bound.
 		 */
-		public boolean isBound(BindingIdentifier identifier) {
+		public boolean isBound(ParameterBinding.BindingIdentifier identifier) {
 			return !getBindings(identifier).isEmpty();
 		}
 
-		BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin,
-				Function<BindingIdentifier, ParameterBinding> bindingFactory, IndexedParameterLabels parameterLabels) {
+		ParameterBinding.BindingIdentifier register(ParameterBinding.BindingIdentifier identifier,
+				ParameterBinding.ParameterOrigin origin,
+				Function<ParameterBinding.BindingIdentifier, ParameterBinding> bindingFactory,
+				IndexedParameterLabels parameterLabels) {
 
-			Assert.isInstanceOf(MethodInvocationArgument.class, origin);
+			Assert.isInstanceOf(ParameterBinding.MethodInvocationArgument.class, origin);
 
-			BindingIdentifier methodArgument = ((MethodInvocationArgument) origin).identifier();
+			ParameterBinding.BindingIdentifier methodArgument = ((ParameterBinding.MethodInvocationArgument) origin)
+					.identifier();
 			List<ParameterBinding> bindingsForOrigin = getBindings(methodArgument);
 
 			if (!isBound(identifier)) {
@@ -614,7 +513,7 @@ BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin,
 				}
 			}
 
-			BindingIdentifier syntheticIdentifier;
+			ParameterBinding.BindingIdentifier syntheticIdentifier;
 			if (identifier.hasName() && methodArgument.hasName()) {
 
 				int index = 0;
@@ -623,9 +522,10 @@ BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin,
 					index++;
 					newName = methodArgument.getName() + "_" + index;
 				}
-				syntheticIdentifier = BindingIdentifier.of(newName);
-			} else {
-				syntheticIdentifier = BindingIdentifier.of(parameterLabels.allocate());
+				syntheticIdentifier = ParameterBinding.BindingIdentifier.of(newName);
+			}
+			else {
+				syntheticIdentifier = ParameterBinding.BindingIdentifier.of(parameterLabels.allocate());
 			}
 
 			ParameterBinding newBinding = bindingFactory.apply(syntheticIdentifier);
@@ -635,11 +535,12 @@ BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin,
 		}
 
 		private boolean existsBoundParameter(String key) {
-			return methodArgumentToLikeBindings.values().stream().flatMap(Collection::stream)
+			return methodArgumentToLikeBindings.values().stream()
+					.flatMap(Collection::stream)
 					.anyMatch(it -> key.equals(it.getName()));
 		}
 
-		private List<ParameterBinding> getBindings(BindingIdentifier identifier) {
+		private List<ParameterBinding> getBindings(ParameterBinding.BindingIdentifier identifier) {
 			return methodArgumentToLikeBindings.computeIfAbsent(identifier, s -> new ArrayList<>());
 		}
 
@@ -647,4 +548,79 @@ public void register(ParameterBinding parameterBinding) {
 			registration.accept(parameterBinding);
 		}
 	}
+
+	/**
+	 * Value object to track and allocate used parameter index labels in a query.
+	 */
+	static class IndexedParameterLabels {
+
+		private final TreeSet<Integer> usedLabels;
+		private final boolean sequential;
+
+		public IndexedParameterLabels(Set<Integer> usedLabels) {
+
+			this.usedLabels = usedLabels instanceof TreeSet<Integer> ts ? ts : new TreeSet<Integer>(usedLabels);
+			this.sequential = isSequential(usedLabels);
+		}
+
+		private static boolean isSequential(Set<Integer> usedLabels) {
+
+			for (int i = 0; i < usedLabels.size(); i++) {
+
+				if (usedLabels.contains(i + 1)) {
+					continue;
+				}
+
+				return false;
+			}
+
+			return true;
+		}
+
+		/**
+		 * Allocate the next index label (1-based).
+		 *
+		 * @return the next index label.
+		 */
+		public int allocate() {
+
+			if (sequential) {
+				int index = usedLabels.size() + 1;
+				usedLabels.add(index);
+
+				return index;
+			}
+
+			int attempts = usedLabels.last() + 1;
+			int index = attemptAllocate(attempts);
+
+			if (index == -1) {
+				throw new IllegalStateException(
+						"Unable to allocate a unique parameter label. All possible labels have been used.");
+			}
+
+			usedLabels.add(index);
+
+			return index;
+		}
+
+		private int attemptAllocate(int attempts) {
+
+			for (int i = 0; i < attempts; i++) {
+
+				if (usedLabels.contains(i + 1)) {
+					continue;
+				}
+
+				return i + 1;
+			}
+
+			return -1;
+		}
+
+		public boolean hasLabels() {
+			return !usedLabels.isEmpty();
+		}
+	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ProcedureParameter.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ProcedureParameter.java
index 0cef0b0a0f..a2f9546d59 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ProcedureParameter.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ProcedureParameter.java
@@ -20,7 +20,7 @@
 
 import java.util.Objects;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * This class represents a Stored Procedure Parameter and an instance of the annotation
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java
index 88d4716d88..2810f957c0 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java
@@ -15,21 +15,33 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import java.util.Set;
+import org.jspecify.annotations.Nullable;
 
 import org.springframework.data.domain.Sort;
 import org.springframework.data.repository.query.ReturnedType;
-import org.springframework.lang.Nullable;
 
 /**
  * This interface describes the API for enhancing a given Query.
  *
  * @author Diego Krupitza
  * @author Greg Turnquist
- * @since 2.7.0
+ * @author Mark Paluch
+ * @since 2.7
  */
 public interface QueryEnhancer {
 
+	/**
+	 * Creates a new {@link QueryEnhancer} for a {@link DeclaredQuery}. Convenience method for
+	 * {@link QueryEnhancerFactory#create(QueryProvider)}.
+	 *
+	 * @param query the query to be enhanced.
+	 * @return the new {@link QueryEnhancer}.
+	 * @since 4.0
+	 */
+	static QueryEnhancer create(DeclaredQuery query) {
+		return QueryEnhancerFactory.forQuery(query).create(query);
+	}
+
 	/**
 	 * Returns whether the given JPQL query contains a constructor expression.
 	 *
@@ -38,9 +50,9 @@ public interface QueryEnhancer {
 	boolean hasConstructorExpression();
 
 	/**
-	 * Resolves the alias for the entity to be retrieved from the given JPA query.
+	 * Resolves the primary alias for the entity to be retrieved from the given JPA query.
 	 *
-	 * @return Might return {@literal null}.
+	 * @return can return {@literal null}.
 	 */
 	@Nullable
 	String detectAlias();
@@ -52,61 +64,24 @@ public interface QueryEnhancer {
 	 */
 	String getProjection();
 
-	/**
-	 * Returns the join aliases of the query.
-	 *
-	 * @return the join aliases of the query.
-	 */
-	@Deprecated(forRemoval = true)
-	Set<String> getJoinAliases();
-
 	/**
 	 * Gets the query we want to use for enhancements.
 	 *
 	 * @return non-null {@link DeclaredQuery} that wraps the query.
 	 */
-	@Deprecated(forRemoval = true)
-	DeclaredQuery getQuery();
-
-	/**
-	 * Adds {@literal order by} clause to the JPQL query. Uses the first alias to bind the sorting property to.
-	 *
-	 * @param sort the sort specification to apply.
-	 * @return the modified query string.
-	 */
-	String applySorting(Sort sort);
-
-	/**
-	 * Adds {@literal order by} clause to the JPQL query.
-	 *
-	 * @param sort the sort specification to apply.
-	 * @param alias the alias to be used in the order by clause. May be {@literal null} or empty.
-	 * @return the modified query string.
-	 * @deprecated since 3.5, use {@link #rewrite(QueryRewriteInformation)} instead.
-	 */
-	@Deprecated(since = "3.5", forRemoval = true)
-	String applySorting(Sort sort, @Nullable String alias);
+	QueryProvider getQuery();
 
 	/**
 	 * Rewrite the query to include sorting and apply {@link ReturnedType} customizations.
 	 *
 	 * @param rewriteInformation the rewrite information to apply.
 	 * @return the modified query string.
-	 * @since 3.5
+	 * @since 4.0
 	 */
 	String rewrite(QueryRewriteInformation rewriteInformation);
 
 	/**
-	 * Creates a count projected query from the given original query.
-	 *
-	 * @return Guaranteed to be not {@literal null}.
-	 */
-	default String createCountQueryFor() {
-		return createCountQueryFor(null);
-	}
-
-	/**
-	 * Creates a count projected query from the given original query using the provided <code>countProjection</code>.
+	 * Creates a count projected query from the given original query using the provided {@code countProjection}.
 	 *
 	 * @param countProjection may be {@literal null}.
 	 * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}.
@@ -116,7 +91,7 @@ default String createCountQueryFor() {
 	/**
 	 * Interface to describe the information needed to rewrite a query.
 	 *
-	 * @since 3.5
+	 * @since 4.0
 	 */
 	interface QueryRewriteInformation {
 
@@ -129,6 +104,7 @@ interface QueryRewriteInformation {
 		 * @return type expected to be returned by the query.
 		 */
 		ReturnedType getReturnedType();
+
 	}
 
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactories.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactories.java
new file mode 100644
index 0000000000..face0778a0
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactories.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright 2024 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.springframework.data.jpa.repository.query;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.data.jpa.provider.PersistenceProvider;
+import org.springframework.util.ClassUtils;
+
+/**
+ * Pre-defined QueryEnhancerFactories to be used for query enhancement.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+public class QueryEnhancerFactories {
+
+	private static final Log LOG = LogFactory.getLog(QueryEnhancerFactories.class);
+
+	static final boolean jSqlParserPresent = ClassUtils.isPresent("net.sf.jsqlparser.parser.JSqlParser",
+			QueryEnhancerFactory.class.getClassLoader());
+
+	static {
+
+		if (jSqlParserPresent) {
+			LOG.info("JSqlParser is in classpath; If applicable, JSqlParser will be used");
+		}
+
+		if (PersistenceProvider.ECLIPSELINK.isPresent()) {
+			LOG.info("EclipseLink is in classpath; If applicable, EQL parser will be used.");
+		}
+
+		if (PersistenceProvider.HIBERNATE.isPresent()) {
+			LOG.info("Hibernate is in classpath; If applicable, HQL parser will be used.");
+		}
+	}
+
+	enum BuiltinQueryEnhancerFactories implements QueryEnhancerFactory {
+
+		FALLBACK {
+			@Override
+			public boolean supports(DeclaredQuery query) {
+				return true;
+			}
+
+			@Override
+			public QueryEnhancer create(QueryProvider query) {
+				return new DefaultQueryEnhancer(query);
+			}
+		},
+
+		JSQLPARSER {
+			@Override
+			public boolean supports(DeclaredQuery query) {
+				return query.isNative();
+			}
+
+			@Override
+			public QueryEnhancer create(QueryProvider query) {
+
+				if (jSqlParserPresent) {
+					return new JSqlParserQueryEnhancer(query);
+				}
+
+				throw new IllegalStateException("JSQLParser is not available on the class path");
+			}
+		},
+
+		HQL {
+			@Override
+			public boolean supports(DeclaredQuery query) {
+				return query.isJpql();
+			}
+
+			@Override
+			public QueryEnhancer create(QueryProvider query) {
+				return JpaQueryEnhancer.forHql(query.getQueryString());
+			}
+		},
+		EQL {
+			@Override
+			public boolean supports(DeclaredQuery query) {
+				return query.isJpql();
+			}
+
+			@Override
+			public QueryEnhancer create(QueryProvider query) {
+				return JpaQueryEnhancer.forEql(query.getQueryString());
+			}
+		},
+		JPQL {
+			@Override
+			public boolean supports(DeclaredQuery query) {
+				return query.isJpql();
+			}
+
+			@Override
+			public QueryEnhancer create(QueryProvider query) {
+				return JpaQueryEnhancer.forJpql(query.getQueryString());
+			}
+		}
+	}
+
+	/**
+	 * Returns the default fallback {@link QueryEnhancerFactory} using regex-based detection. This factory supports only
+	 * simple SQL queries.
+	 *
+	 * @return fallback {@link QueryEnhancerFactory} using regex-based detection.
+	 */
+	public static QueryEnhancerFactory fallback() {
+		return BuiltinQueryEnhancerFactories.FALLBACK;
+	}
+
+	/**
+	 * Returns a {@link QueryEnhancerFactory} that uses <a href="https://github.com/JSQLParser/JSqlParser">JSqlParser</a>
+	 * if it is available from the class path.
+	 *
+	 * @return a {@link QueryEnhancerFactory} that uses <a href="https://github.com/JSQLParser/JSqlParser">JSqlParser</a>.
+	 * @throws IllegalStateException if JSQLParser is not on the class path.
+	 */
+	public static QueryEnhancerFactory jsqlparser() {
+
+		if (!jSqlParserPresent) {
+			throw new IllegalStateException("JSQLParser is not available on the class path");
+		}
+
+		return BuiltinQueryEnhancerFactories.JSQLPARSER;
+	}
+
+	/**
+	 * Returns a {@link QueryEnhancerFactory} using HQL (Hibernate Query Language) parser.
+	 *
+	 * @return a {@link QueryEnhancerFactory} using HQL (Hibernate Query Language) parser.
+	 */
+	public static QueryEnhancerFactory hql() {
+		return BuiltinQueryEnhancerFactories.HQL;
+	}
+
+	/**
+	 * Returns a {@link QueryEnhancerFactory} using EQL (EclipseLink Query Language) parser.
+	 *
+	 * @return a {@link QueryEnhancerFactory} using EQL (EclipseLink Query Language) parser.
+	 */
+	public static QueryEnhancerFactory eql() {
+		return BuiltinQueryEnhancerFactories.EQL;
+	}
+
+	/**
+	 * Returns a {@link QueryEnhancerFactory} using JPQL (Jakarta Persistence Query Language) parser as per the JPA spec.
+	 *
+	 * @return a {@link QueryEnhancerFactory} using JPQL (Jakarta Persistence Query Language) parser as per the JPA spec.
+	 */
+	public static QueryEnhancerFactory jpql() {
+		return BuiltinQueryEnhancerFactories.JPQL;
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactory.java
index 5a2853cb1a..0233798594 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactory.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactory.java
@@ -15,133 +15,41 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import org.apache.commons.logging.Log;
-import org.apache.commons.logging.LogFactory;
-import org.springframework.core.SpringProperties;
-import org.springframework.data.jpa.provider.PersistenceProvider;
-import org.springframework.util.ClassUtils;
-import org.springframework.util.ObjectUtils;
-import org.springframework.util.StringUtils;
-
 /**
- * Encapsulates different strategies for the creation of a {@link QueryEnhancer} from a {@link DeclaredQuery}.
+ * Encapsulates different strategies for the creation of a {@link QueryEnhancer} from a {@link ParametrizedQuery}.
  *
  * @author Diego Krupitza
  * @author Greg Turnquist
  * @author Mark Paluch
  * @author Christoph Strobl
- * @since 2.7.0
+ * @since 4.0
  */
-public final class QueryEnhancerFactory {
-
-	private static final Log LOG = LogFactory.getLog(QueryEnhancerFactory.class);
-	private static final NativeQueryEnhancer NATIVE_QUERY_ENHANCER;
-
-	static {
-
-		NATIVE_QUERY_ENHANCER = NativeQueryEnhancer.select();
-
-		if (PersistenceProvider.ECLIPSELINK.isPresent()) {
-			LOG.info("EclipseLink is in classpath; If applicable, EQL parser will be used.");
-		}
-
-		if (PersistenceProvider.HIBERNATE.isPresent()) {
-			LOG.info("Hibernate is in classpath; If applicable, HQL parser will be used.");
-		}
-	}
-
-	private QueryEnhancerFactory() {}
+public interface QueryEnhancerFactory {
 
 	/**
-	 * Creates a new {@link QueryEnhancer} for the given {@link DeclaredQuery}.
+	 * Returns whether this QueryEnhancerFactory supports the given {@link DeclaredQuery}.
 	 *
-	 * @param query must not be {@literal null}.
-	 * @return an implementation of {@link QueryEnhancer} that suits the query the most
+	 * @param query the query to be enhanced and introspected.
+	 * @return {@code true} if this QueryEnhancer supports the given query; {@code false} otherwise.
 	 */
-	public static QueryEnhancer forQuery(DeclaredQuery query) {
-
-		if (query.isNativeQuery()) {
-			return getNativeQueryEnhancer(query);
-		}
-
-		if (PersistenceProvider.HIBERNATE.isPresent()) {
-			return JpaQueryEnhancer.forHql(query);
-		} else if (PersistenceProvider.ECLIPSELINK.isPresent()) {
-			return JpaQueryEnhancer.forEql(query);
-		} else {
-			return JpaQueryEnhancer.forJpql(query);
-		}
-	}
+	boolean supports(DeclaredQuery query);
 
 	/**
-	 * Get the native query enhancer for the given {@link DeclaredQuery query} based on {@link #NATIVE_QUERY_ENHANCER}.
+	 * Creates a new {@link QueryEnhancer} for the given query.
 	 *
-	 * @param query the declared query.
-	 * @return new instance of {@link QueryEnhancer}.
+	 * @param query the query to be enhanced and introspected.
+	 * @return the query enhancer to be used.
 	 */
-	private static QueryEnhancer getNativeQueryEnhancer(DeclaredQuery query) {
-
-		if (NATIVE_QUERY_ENHANCER.equals(NativeQueryEnhancer.JSQLPARSER)) {
-			return new JSqlParserQueryEnhancer(query);
-		}
-
-		return new DefaultQueryEnhancer(query);
-	}
+	QueryEnhancer create(QueryProvider query);
 
 	/**
-	 * Possible choices for the {@link #NATIVE_PARSER_PROPERTY}. Resolve the parser through {@link #select()}.
+	 * Creates a new {@link QueryEnhancerFactory} for the given {@link DeclaredQuery}.
 	 *
-	 * @since 3.3.5
+	 * @param query must not be {@literal null}.
+	 * @return an implementation of {@link QueryEnhancer} that suits the query the most
 	 */
-	enum NativeQueryEnhancer {
-
-		AUTO, REGEX, JSQLPARSER;
-
-		static final String NATIVE_PARSER_PROPERTY = "spring.data.jpa.query.native.parser";
-
-		static final boolean JSQLPARSER_PRESENT = ClassUtils.isPresent("net.sf.jsqlparser.parser.JSqlParser", null);
-
-		/**
-		 * @return the current selection considering classpath availability and user selection via
-		 *         {@link #NATIVE_PARSER_PROPERTY}.
-		 */
-		static NativeQueryEnhancer select() {
-
-			NativeQueryEnhancer selected = resolve();
-
-			if (selected.equals(NativeQueryEnhancer.JSQLPARSER)) {
-				LOG.info("User choice: Using JSqlParser");
-				return NativeQueryEnhancer.JSQLPARSER;
-			}
-
-			if (selected.equals(NativeQueryEnhancer.REGEX)) {
-				LOG.info("Using Regex QueryEnhancer");
-				return NativeQueryEnhancer.REGEX;
-			}
-
-			if (!JSQLPARSER_PRESENT) {
-				return NativeQueryEnhancer.REGEX;
-			}
-
-			LOG.info("JSqlParser is in classpath; If applicable, JSqlParser will be used.");
-			return NativeQueryEnhancer.JSQLPARSER;
-		}
-
-		/**
-		 * Resolve {@link NativeQueryEnhancer} from {@link SpringProperties}.
-		 *
-		 * @return the {@link NativeQueryEnhancer} constant.
-		 */
-		private static NativeQueryEnhancer resolve() {
-
-			String name = SpringProperties.getProperty(NATIVE_PARSER_PROPERTY);
-
-			if (StringUtils.hasText(name)) {
-				return ObjectUtils.caseInsensitiveValueOf(NativeQueryEnhancer.values(), name);
-			}
-
-			return AUTO;
-		}
+	static QueryEnhancerFactory forQuery(DeclaredQuery query) {
+		return QueryEnhancerSelector.DEFAULT_SELECTOR.select(query);
 	}
 
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerSelector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerSelector.java
new file mode 100644
index 0000000000..fd5f1da6ae
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerSelector.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright 2024 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.springframework.data.jpa.repository.query;
+
+import org.springframework.data.jpa.provider.PersistenceProvider;
+
+/**
+ * Interface declaring a strategy to select a {@link QueryEnhancer} for a given {@link DeclaredQuery query}.
+ * <p>
+ * Enhancers are selected when introspecting a query to determine their selection, joins, aliases and other information
+ * so that query methods can derive count queries, apply sorting and perform other rewrite transformations.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+public interface QueryEnhancerSelector {
+
+	/**
+	 * Default selector strategy.
+	 */
+	QueryEnhancerSelector DEFAULT_SELECTOR = new DefaultQueryEnhancerSelector();
+
+	/**
+	 * Select a {@link QueryEnhancer} for a {@link DeclaredQuery query}.
+	 *
+	 * @param query
+	 * @return
+	 */
+	QueryEnhancerFactory select(DeclaredQuery query);
+
+	/**
+	 * Default {@link QueryEnhancerSelector} implementation using class-path information to determine enhancer
+	 * availability. Subclasses may provide a different configuration by using the protected constructor.
+	 */
+	class DefaultQueryEnhancerSelector implements QueryEnhancerSelector {
+
+		protected static QueryEnhancerFactory DEFAULT_NATIVE;
+		protected static QueryEnhancerFactory DEFAULT_JPQL;
+
+		static {
+
+			DEFAULT_NATIVE = QueryEnhancerFactories.jSqlParserPresent ? QueryEnhancerFactories.jsqlparser()
+					: QueryEnhancerFactories.fallback();
+
+			if (PersistenceProvider.HIBERNATE.isPresent()) {
+				DEFAULT_JPQL = QueryEnhancerFactories.hql();
+			} else if (PersistenceProvider.ECLIPSELINK.isPresent()) {
+				DEFAULT_JPQL = QueryEnhancerFactories.eql();
+			} else {
+				DEFAULT_JPQL = QueryEnhancerFactories.jpql();
+			}
+		}
+
+		private final QueryEnhancerFactory nativeQuery;
+		private final QueryEnhancerFactory jpql;
+
+		DefaultQueryEnhancerSelector() {
+			this(DEFAULT_NATIVE, DEFAULT_JPQL);
+		}
+
+		protected DefaultQueryEnhancerSelector(QueryEnhancerFactory nativeQuery, QueryEnhancerFactory jpql) {
+			this.nativeQuery = nativeQuery;
+			this.jpql = jpql;
+		}
+
+		/**
+		 * Returns the default JPQL {@link QueryEnhancerFactory} based on class path presence of Hibernate and EclipseLink.
+		 *
+		 * @return the default JPQL {@link QueryEnhancerFactory}.
+		 */
+		public static QueryEnhancerFactory jpql() {
+			return DEFAULT_JPQL;
+		}
+
+		@Override
+		public QueryEnhancerFactory select(DeclaredQuery query) {
+			return jpql.supports(query) ? jpql : nativeQuery;
+		}
+
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformation.java
index 07c1def305..b681037cbf 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformation.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformation.java
@@ -17,7 +17,7 @@
 
 import java.util.List;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Value object capturing introspection details of a parsed query.
@@ -44,8 +44,7 @@ class QueryInformation {
 	 *
 	 * @return
 	 */
-	@Nullable
-	public String getAlias() {
+	public @Nullable String getAlias() {
 		return alias;
 	}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java
index 727f61cc81..caeb8fd78f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java
@@ -23,17 +23,16 @@
 import jakarta.persistence.criteria.ParameterExpression;
 
 import java.lang.reflect.Proxy;
-import java.util.Collections;
 import java.util.Date;
-import java.util.HashMap;
-import java.util.Map;
 import java.util.Set;
 import java.util.function.Function;
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.util.Assert;
+import org.springframework.util.ErrorHandler;
 
 /**
  * The interface encapsulates the setting of query parameters which might use a significant number of variations of
@@ -45,158 +44,159 @@
  */
 interface QueryParameterSetter {
 
-	void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandling errorHandling);
-
 	/** Noop implementation */
-	QueryParameterSetter NOOP = (query, values, errorHandling) -> {};
+	QueryParameterSetter NOOP = (query, values, errorHandler) -> {};
+
+	/**
+	 * Creates a new {@link QueryParameterSetter} for the given value extractor, JPA parameter and potentially the
+	 * temporal type.
+	 *
+	 * @param valueExtractor
+	 * @param parameter
+	 * @param temporalType
+	 * @return
+	 */
+	static QueryParameterSetter create(Function<JpaParametersParameterAccessor, Object> valueExtractor,
+			Parameter<?> parameter, @Nullable TemporalType temporalType) {
+
+		return temporalType == null ? new NamedOrIndexedQueryParameterSetter(valueExtractor, parameter)
+				: new TemporalParameterSetter(valueExtractor, parameter, temporalType);
+	}
+
+	void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandler errorHandler);
 
 	/**
-	 * {@link QueryParameterSetter} for named or indexed parameters that might have a {@link TemporalType} specified.
+	 * {@link QueryParameterSetter} for named or indexed parameters.
 	 */
 	class NamedOrIndexedQueryParameterSetter implements QueryParameterSetter {
 
 		private final Function<JpaParametersParameterAccessor, Object> valueExtractor;
 		private final Parameter<?> parameter;
-		private final @Nullable TemporalType temporalType;
 
 		/**
 		 * @param valueExtractor must not be {@literal null}.
 		 * @param parameter must not be {@literal null}.
-		 * @param temporalType may be {@literal null}.
 		 */
-		NamedOrIndexedQueryParameterSetter(Function<JpaParametersParameterAccessor, Object> valueExtractor,
-				Parameter<?> parameter, @Nullable TemporalType temporalType) {
+		private NamedOrIndexedQueryParameterSetter(Function<JpaParametersParameterAccessor, Object> valueExtractor,
+				Parameter<?> parameter) {
 
 			Assert.notNull(valueExtractor, "ValueExtractor must not be null");
 
 			this.valueExtractor = valueExtractor;
 			this.parameter = parameter;
-			this.temporalType = temporalType;
 		}
 
-		@SuppressWarnings("unchecked")
 		@Override
-		public void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor,
-				ErrorHandling errorHandling) {
+		public void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandler errorHandler) {
 
-			if (temporalType != null) {
+			Object value = valueExtractor.apply(accessor);
 
-				Object extractedValue = valueExtractor.apply(accessor);
-
-				Date value = (Date) accessor.potentiallyUnwrap(extractedValue);
+			try {
+				setParameter(query, value, errorHandler);
+			} catch (RuntimeException e) {
+				errorHandler.handleError(e);
+			}
+		}
 
-				// One would think we can simply use parameter to identify the parameter we want to set.
-				// But that does not work with list valued parameters. At least Hibernate tries to bind them by name.
-				// TODO: move to using setParameter(Parameter, value) when https://hibernate.atlassian.net/browse/HHH-11870 is
-				// fixed.
+		@SuppressWarnings("unchecked")
+		private void setParameter(BindableQuery query, Object value, ErrorHandler errorHandler) {
 
-				if (parameter instanceof ParameterExpression) {
-					errorHandling.execute(() -> query.setParameter((Parameter<Date>) parameter, value, temporalType));
-				} else if (query.hasNamedParameters() && parameter.getName() != null) {
-					errorHandling.execute(() -> query.setParameter(parameter.getName(), value, temporalType));
-				} else {
+			if (parameter instanceof ParameterExpression) {
+				query.setParameter((Parameter<Object>) parameter, value);
+			} else if (query.hasNamedParameters() && parameter.getName() != null) {
+				query.setParameter(parameter.getName(), value);
 
-					Integer position = parameter.getPosition();
+			} else {
 
-					if (position != null //
-							&& (query.getParameters().size() >= parameter.getPosition() //
-									|| query.registerExcessParameters() //
-									|| errorHandling == LENIENT)) {
+				Integer position = parameter.getPosition();
 
-						errorHandling.execute(() -> query.setParameter(parameter.getPosition(), value, temporalType));
-					}
+				if (position != null //
+						&& (query.getParameters().size() >= position //
+								|| errorHandler == LENIENT //
+								|| query.registerExcessParameters())) {
+					query.setParameter(position, value);
 				}
+			}
+		}
+	}
 
-			} else {
+	/**
+	 * {@link QueryParameterSetter} for named or indexed parameters that have a {@link TemporalType} specified.
+	 */
+	class TemporalParameterSetter implements QueryParameterSetter {
+
+		private final Function<JpaParametersParameterAccessor, Object> valueExtractor;
+		private final Parameter<?> parameter;
+		private final TemporalType temporalType;
+
+		private TemporalParameterSetter(Function<JpaParametersParameterAccessor, Object> valueExtractor,
+				Parameter<?> parameter, TemporalType temporalType) {
+			this.valueExtractor = valueExtractor;
+			this.parameter = parameter;
+			this.temporalType = temporalType;
+		}
+
+		@Override
+		public void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandler errorHandler) {
+
+			Date value = (Date) accessor.potentiallyUnwrap(valueExtractor.apply(accessor));
+
+			try {
+				setParameter(query, value, errorHandler);
+			} catch (RuntimeException e) {
+				errorHandler.handleError(e);
+			}
+		}
+
+		@SuppressWarnings("unchecked")
+		private void setParameter(BindableQuery query, Date date, ErrorHandler errorHandler) {
 
-				Object value = valueExtractor.apply(accessor);
+			// One would think we can simply use parameter to identify the parameter we want to set.
+			// But that does not work with list valued parameters. At least Hibernate tries to bind them by name.
+			// TODO: move to using setParameter(Parameter, value) when https://hibernate.atlassian.net/browse/HHH-11870 is
+			// fixed.
 
-				if (parameter instanceof ParameterExpression) {
-					errorHandling.execute(() -> query.setParameter((Parameter<Object>) parameter, value));
-				} else if (query.hasNamedParameters() && parameter.getName() != null) {
-					errorHandling.execute(() -> query.setParameter(parameter.getName(), value));
+			if (parameter instanceof ParameterExpression) {
+				query.setParameter((Parameter<Date>) parameter, date, temporalType);
+			} else if (query.hasNamedParameters() && parameter.getName() != null) {
+				query.setParameter(parameter.getName(), date, temporalType);
+			} else {
 
-				} else {
+				Integer position = parameter.getPosition();
 
-					Integer position = parameter.getPosition();
+				if (position != null //
+						&& (query.getParameters().size() >= parameter.getPosition() //
+								|| query.registerExcessParameters() //
+								|| errorHandler == LENIENT)) {
 
-					if (position != null //
-							&& (query.getParameters().size() >= position //
-									|| errorHandling == LENIENT //
-									|| query.registerExcessParameters())) {
-						errorHandling.execute(() -> query.setParameter(position, value));
-					}
+					query.setParameter(parameter.getPosition(), date, temporalType);
 				}
 			}
 		}
 	}
 
-	enum ErrorHandling {
+	enum ErrorHandling implements ErrorHandler {
 
 		STRICT {
 
 			@Override
-			public void execute(Runnable block) {
-				block.run();
+			public void handleError(Throwable t) {
+				if (t instanceof RuntimeException rx) {
+					throw rx;
+				}
+				throw new RuntimeException(t);
 			}
 		},
 
 		LENIENT {
 
 			@Override
-			public void execute(Runnable block) {
-
-				try {
-					block.run();
-				} catch (RuntimeException rex) {
-					LOG.info("Silently ignoring", rex);
-				}
+			public void handleError(Throwable t) {
+				LOG.info("Silently ignoring", t);
 			}
 		};
 
 		private static final Log LOG = LogFactory.getLog(ErrorHandling.class);
-
-		abstract void execute(Runnable block);
-	}
-
-	/**
-	 * Cache for {@link QueryMetadata}. Optimizes for small cache sizes on a best-effort basis.
-	 */
-	class QueryMetadataCache {
-
-		private Map<String, QueryMetadata> cache = Collections.emptyMap();
-
-		/**
-		 * Retrieve the {@link QueryMetadata} for a given {@code cacheKey}.
-		 *
-		 * @param cacheKey
-		 * @param query
-		 * @return
-		 */
-		public QueryMetadata getMetadata(String cacheKey, Query query) {
-
-			QueryMetadata queryMetadata = cache.get(cacheKey);
-
-			if (queryMetadata == null) {
-
-				queryMetadata = new QueryMetadata(query);
-
-				Map<String, QueryMetadata> cache;
-
-				if (this.cache.isEmpty()) {
-					cache = Collections.singletonMap(cacheKey, queryMetadata);
-				} else {
-					cache = new HashMap<>(this.cache);
-					cache.put(cacheKey, queryMetadata);
-				}
-
-				synchronized (this) {
-					this.cache = cache;
-				}
-			}
-
-			return queryMetadata;
-		}
 	}
 
 	/**
@@ -224,23 +224,6 @@ class QueryMetadata {
 					&& unwrapClass(query).getName().startsWith("org.eclipse");
 		}
 
-		QueryMetadata(QueryMetadata metadata) {
-
-			this.namedParameters = metadata.namedParameters;
-			this.parameters = metadata.parameters;
-			this.registerExcessParameters = metadata.registerExcessParameters;
-		}
-
-		/**
-		 * Create a {@link BindableQuery} for a {@link Query}.
-		 *
-		 * @param query
-		 * @return
-		 */
-		public BindableQuery withQuery(Query query) {
-			return new BindableQuery(this, query);
-		}
-
 		/**
 		 * @return
 		 */
@@ -294,13 +277,7 @@ class BindableQuery extends QueryMetadata {
 		private final Query query;
 		private final Query unwrapped;
 
-		BindableQuery(QueryMetadata metadata, Query query) {
-			super(metadata);
-			this.query = query;
-			this.unwrapped = Proxy.isProxyClass(query.getClass()) ? query.unwrap(null) : query;
-		}
-
-		private BindableQuery(Query query) {
+		BindableQuery(Query query) {
 			super(query);
 			this.query = query;
 			this.unwrapped = Proxy.isProxyClass(query.getClass()) ? query.unwrap(null) : query;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactory.java
index c45c3d8aa3..6d6196b8ef 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactory.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactory.java
@@ -18,9 +18,10 @@
 import jakarta.persistence.Query;
 import jakarta.persistence.TemporalType;
 
-import java.util.List;
 import java.util.function.Function;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.expression.ValueEvaluationContext;
 import org.springframework.data.expression.ValueEvaluationContextProvider;
 import org.springframework.data.expression.ValueExpression;
@@ -28,14 +29,11 @@
 import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter;
 import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier;
 import org.springframework.data.jpa.repository.query.ParameterBinding.MethodInvocationArgument;
-import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata;
-import org.springframework.data.jpa.repository.query.QueryParameterSetter.NamedOrIndexedQueryParameterSetter;
 import org.springframework.data.repository.query.Parameter;
 import org.springframework.data.repository.query.Parameters;
 import org.springframework.data.spel.EvaluationContextProvider;
 import org.springframework.expression.Expression;
 import org.springframework.expression.spel.standard.SpelExpressionParser;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
@@ -49,36 +47,44 @@
  */
 abstract class QueryParameterSetterFactory {
 
-	@Nullable
-	abstract QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery);
+	/**
+	 * Creates a {@link QueryParameterSetter} for the given {@link ParameterBinding}. This factory may return
+	 * {@literal null} if it doesn't support the given {@link ParameterBinding}.
+	 *
+	 * @param binding the parameter binding to create a {@link QueryParameterSetter} for.
+	 * @return
+	 */
+	abstract @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery parametrizedQuery);
 
 	/**
 	 * Creates a new {@link QueryParameterSetterFactory} for the given {@link JpaParameters}.
 	 *
 	 * @param parameters must not be {@literal null}.
+	 * @param preferNamedParameters whether to prefer named parameters.
 	 * @return a basic {@link QueryParameterSetterFactory} that can handle named and index parameters.
 	 */
-	static QueryParameterSetterFactory basic(JpaParameters parameters) {
-
-		Assert.notNull(parameters, "JpaParameters must not be null");
-
-		return new BasicQueryParameterSetterFactory(parameters);
+	static QueryParameterSetterFactory basic(JpaParameters parameters, boolean preferNamedParameters) {
+		return new BasicQueryParameterSetterFactory(parameters, preferNamedParameters);
 	}
 
 	/**
-	 * Creates a new {@link QueryParameterSetterFactory} using the given {@link JpaParameters} and
-	 * {@link ParameterMetadata}.
+	 * Creates a new {@link QueryParameterSetterFactory} using the given {@link JpaParameters}.
 	 *
 	 * @param parameters must not be {@literal null}.
-	 * @param metadata must not be {@literal null}.
-	 * @return a {@link QueryParameterSetterFactory} for criteria Queries.
+	 * @return a {@link QueryParameterSetterFactory} for Part-Tree Queries.
 	 */
-	static QueryParameterSetterFactory forCriteriaQuery(JpaParameters parameters, List<ParameterMetadata<?>> metadata) {
-
-		Assert.notNull(parameters, "JpaParameters must not be null");
-		Assert.notNull(metadata, "ParameterMetadata must not be null");
+	static QueryParameterSetterFactory forPartTreeQuery(JpaParameters parameters) {
+		return new PartTreeQueryParameterSetterFactory(parameters);
+	}
 
-		return new CriteriaQueryParameterSetterFactory(parameters, metadata);
+	/**
+	 * Creates a new {@link QueryParameterSetterFactory} to bind
+	 * {@link org.springframework.data.jpa.repository.query.ParameterBinding.Synthetic} parameters.
+	 *
+	 * @return a {@link QueryParameterSetterFactory} for JPQL Queries.
+	 */
+	static QueryParameterSetterFactory forSynthetic() {
+		return new SyntheticParameterSetterFactory();
 	}
 
 	/**
@@ -87,16 +93,11 @@ static QueryParameterSetterFactory forCriteriaQuery(JpaParameters parameters, Li
 	 *
 	 * @param parser must not be {@literal null}.
 	 * @param evaluationContextProvider must not be {@literal null}.
-	 * @param parameters must not be {@literal null}.
 	 * @return a {@link QueryParameterSetterFactory} that can handle
 	 *         {@link org.springframework.expression.spel.standard.SpelExpression}s.
 	 */
 	static QueryParameterSetterFactory parsing(ValueExpressionParser parser,
 			ValueEvaluationContextProvider evaluationContextProvider) {
-
-		Assert.notNull(parser, "ValueExpressionParser must not be null");
-		Assert.notNull(evaluationContextProvider, "ValueEvaluationContextProvider must not be null");
-
 		return new ExpressionBasedQueryParameterSetterFactory(parser, evaluationContextProvider);
 	}
 
@@ -108,19 +109,18 @@ static QueryParameterSetterFactory parsing(ValueExpressionParser parser,
 	 * @param binding the binding of the query parameter to be set.
 	 * @param parameter the method parameter to bind.
 	 */
-	private static QueryParameterSetter createSetter(Function<JpaParametersParameterAccessor, Object> valueExtractor,
+	private static QueryParameterSetter createSetter(Function<JpaParametersParameterAccessor, @Nullable Object> valueExtractor,
 			ParameterBinding binding, @Nullable JpaParameter parameter) {
 
 		TemporalType temporalType = parameter != null && parameter.isTemporalParameter() //
 				? parameter.getRequiredTemporalType() //
 				: null;
 
-		return new NamedOrIndexedQueryParameterSetter(valueExtractor.andThen(binding::prepare),
-				ParameterImpl.of(parameter, binding), temporalType);
+		return QueryParameterSetter.create(valueExtractor.andThen(binding::prepare), ParameterImpl.of(parameter, binding),
+				temporalType);
 	}
 
-	@Nullable
-	static JpaParameter findParameterForBinding(Parameters<JpaParameters, JpaParameter> parameters, String name) {
+	static @Nullable JpaParameter findParameterForBinding(Parameters<JpaParameters, JpaParameter> parameters, String name) {
 
 		JpaParameters bindableParameters = parameters.getBindableParameters();
 
@@ -168,7 +168,6 @@ private static class ExpressionBasedQueryParameterSetterFactory extends QueryPar
 		/**
 		 * @param parser must not be {@literal null}.
 		 * @param evaluationContextProvider must not be {@literal null}.
-		 * @param parameters must not be {@literal null}.
 		 */
 		ExpressionBasedQueryParameterSetterFactory(ValueExpressionParser parser,
 				ValueEvaluationContextProvider evaluationContextProvider) {
@@ -180,9 +179,8 @@ private static class ExpressionBasedQueryParameterSetterFactory extends QueryPar
 			this.evaluationContextProvider = evaluationContextProvider;
 		}
 
-		@Nullable
 		@Override
-		public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery) {
+		public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery parametrizedQuery) {
 
 			if (!(binding.getOrigin() instanceof ParameterBinding.Expression e)) {
 				return null;
@@ -198,14 +196,32 @@ public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery decla
 		 * @param accessor must not be {@literal null}.
 		 * @return the result of the evaluation.
 		 */
-		@Nullable
-		private Object evaluateExpression(ValueExpression expression, JpaParametersParameterAccessor accessor) {
+		private @Nullable Object evaluateExpression(ValueExpression expression, JpaParametersParameterAccessor accessor) {
 
 			ValueEvaluationContext evaluationContext = evaluationContextProvider.getEvaluationContext(accessor.getValues());
 			return expression.evaluate(evaluationContext);
 		}
 	}
 
+	/**
+	 * Handles synthetic bindings that have been captured during parameter augmenting.
+	 *
+	 * @author Mark Paluch
+	 * @since 4.0
+	 */
+	private static class SyntheticParameterSetterFactory extends QueryParameterSetterFactory {
+
+		@Override
+		public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery query) {
+
+			if (!(binding.getOrigin() instanceof ParameterBinding.Synthetic s)) {
+				return null;
+			}
+
+			return createSetter(values -> s.value(), binding, null);
+		}
+	}
+
 	/**
 	 * Extracts values for parameter bindings from method parameters. It handles named as well as indexed parameters.
 	 *
@@ -217,30 +233,33 @@ private Object evaluateExpression(ValueExpression expression, JpaParametersParam
 	private static class BasicQueryParameterSetterFactory extends QueryParameterSetterFactory {
 
 		private final JpaParameters parameters;
+		private final boolean preferNamedParameters;
 
 		/**
 		 * @param parameters must not be {@literal null}.
+		 * @param preferNamedParameters whether to use named parameters.
 		 */
-		BasicQueryParameterSetterFactory(JpaParameters parameters) {
+		BasicQueryParameterSetterFactory(JpaParameters parameters, boolean preferNamedParameters) {
 
 			Assert.notNull(parameters, "JpaParameters must not be null");
 
 			this.parameters = parameters;
+			this.preferNamedParameters = preferNamedParameters;
 		}
 
 		@Override
-		public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery) {
+		public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery query) {
 
 			Assert.notNull(binding, "Binding must not be null");
 
-			JpaParameter parameter;
 			if (!(binding.getOrigin() instanceof MethodInvocationArgument mia)) {
-				return QueryParameterSetter.NOOP;
+				return null;
 			}
 
 			BindingIdentifier identifier = mia.identifier();
+			JpaParameter parameter;
 
-			if (declaredQuery.hasNamedParameter() && identifier.hasName()) {
+			if (preferNamedParameters && identifier.hasName()) {
 				parameter = findParameterForBinding(parameters, identifier.getName());
 			} else if (identifier.hasPosition()) {
 				parameter = findParameterForBinding(parameters, identifier.getPosition() - 1);
@@ -254,8 +273,7 @@ public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery decla
 					: createSetter(values -> getValue(values, parameter), binding, parameter);
 		}
 
-		@Nullable
-		private Object getValue(JpaParametersParameterAccessor accessor, Parameter parameter) {
+		protected @Nullable Object getValue(JpaParametersParameterAccessor accessor, Parameter parameter) {
 			return accessor.getValue(parameter);
 		}
 	}
@@ -263,60 +281,31 @@ private Object getValue(JpaParametersParameterAccessor accessor, Parameter param
 	/**
 	 * @author Jens Schauder
 	 * @author Oliver Gierke
+	 * @author Mark Paluch
 	 * @see QueryParameterSetterFactory
 	 */
-	private static class CriteriaQueryParameterSetterFactory extends QueryParameterSetterFactory {
+	private static class PartTreeQueryParameterSetterFactory extends BasicQueryParameterSetterFactory {
 
 		private final JpaParameters parameters;
-		private final List<ParameterMetadata<?>> parameterMetadata;
-
-		/**
-		 * Creates a new {@link QueryParameterSetterFactory} from the given {@link JpaParameters} and
-		 * {@link ParameterMetadata}.
-		 *
-		 * @param parameters must not be {@literal null}.
-		 * @param metadata must not be {@literal null}.
-		 */
-		CriteriaQueryParameterSetterFactory(JpaParameters parameters, List<ParameterMetadata<?>> metadata) {
 
-			Assert.notNull(parameters, "JpaParameters must not be null");
-			Assert.notNull(metadata, "Expressions must not be null");
-
-			this.parameters = parameters;
-			this.parameterMetadata = metadata;
+		private PartTreeQueryParameterSetterFactory(JpaParameters parameters) {
+			super(parameters, false);
+			this.parameters = parameters.getBindableParameters();
 		}
 
 		@Override
-		public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery) {
+		public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery query) {
 
-			int parameterIndex = binding.getRequiredPosition() - 1;
+			if (binding instanceof ParameterBinding.PartTreeParameterBinding ptb) {
 
-			Assert.isTrue( //
-					parameterIndex < parameterMetadata.size(), //
-					() -> String.format( //
-							"At least %s parameter(s) provided but only %s parameter(s) present in query", //
-							binding.getRequiredPosition(), //
-							parameterMetadata.size() //
-					) //
-			);
+				if (ptb.isIsNullParameter()) {
+					return QueryParameterSetter.NOOP;
+				}
 
-			ParameterMetadata<?> metadata = parameterMetadata.get(parameterIndex);
-
-			if (metadata.isIsNullParameter()) {
-				return QueryParameterSetter.NOOP;
+				return super.create(binding, query);
 			}
 
-			JpaParameter parameter = parameters.getBindableParameter(parameterIndex);
-			TemporalType temporalType = parameter.isTemporalParameter() ? parameter.getRequiredTemporalType() : null;
-
-			return new NamedOrIndexedQueryParameterSetter(values -> getAndPrepare(parameter, metadata, values),
-					metadata.getExpression(), temporalType);
-		}
-
-		@Nullable
-		private Object getAndPrepare(JpaParameter parameter, ParameterMetadata<?> metadata,
-				JpaParametersParameterAccessor accessor) {
-			return metadata.prepare(accessor.getValue(parameter));
+			return null;
 		}
 	}
 
@@ -344,15 +333,13 @@ public ParameterImpl(BindingIdentifier identifier, Class<T> parameterType) {
 			this.parameterType = parameterType;
 		}
 
-		@Nullable
 		@Override
-		public String getName() {
+		public @Nullable String getName() {
 			return identifier.hasName() ? identifier.getName() : null;
 		}
 
-		@Nullable
 		@Override
-		public Integer getPosition() {
+		public @Nullable Integer getPosition() {
 			return identifier.hasPosition() ? identifier.getPosition() : null;
 		}
 
@@ -360,7 +347,6 @@ public Integer getPosition() {
 		public Class<T> getParameterType() {
 			return parameterType;
 		}
-
 	}
 
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaParameterMetadataProviderIntegrationTests.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryProvider.java
similarity index 56%
rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaParameterMetadataProviderIntegrationTests.java
rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryProvider.java
index 7517a2a7e1..98de7da6eb 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaParameterMetadataProviderIntegrationTests.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryProvider.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2015-2025 the original author or authors.
+ * Copyright 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.
@@ -15,13 +15,23 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import org.springframework.test.context.ContextConfiguration;
-
 /**
- * OpenJpa-specific tests for {@link ParameterMetadataProvider}.
+ * Interface indicating an object that contains and exposes an {@code query string}. This can be either a JPQL query
+ * string or a SQL query string.
  *
- * @author Oliver Gierke
- * @soundtrack Elephants Crossing - We are (Irrelephant)
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @since 4.0
+ * @see DeclaredQuery#jpqlQuery(String)
+ * @see DeclaredQuery#nativeQuery(String)
  */
-@ContextConfiguration("classpath:openjpa.xml")
-class OpenJpaParameterMetadataProviderIntegrationTests extends ParameterMetadataProviderIntegrationTests {}
+public interface QueryProvider {
+
+	/**
+	 * Return the query string.
+	 *
+	 * @return the query string.
+	 */
+	String getQueryString();
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRenderer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRenderer.java
index 3039ef735a..5c0969ea2b 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRenderer.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRenderer.java
@@ -20,11 +20,12 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
-import java.util.function.Function;
 import java.util.stream.Stream;
 
 import org.springframework.util.CompositeIterator;
 
+import org.jspecify.annotations.Nullable;
+
 /**
  * Abstraction to encapsulate query expressions and render a query.
  * <p>
@@ -44,9 +45,6 @@ abstract class QueryRenderer implements QueryTokenStream {
 
 	/**
 	 * Creates a QueryRenderer from a {@link QueryToken}.
-	 *
-	 * @param token
-	 * @return
 	 */
 	static QueryRenderer from(QueryToken token) {
 		return QueryRenderer.from(Collections.singletonList(token));
@@ -54,9 +52,6 @@ static QueryRenderer from(QueryToken token) {
 
 	/**
 	 * Creates a QueryRenderer from a collection of {@link QueryToken}.
-	 *
-	 * @param tokens
-	 * @return
 	 */
 	static QueryRenderer from(Collection<? extends QueryToken> tokens) {
 		List<QueryToken> tokensToUse = new ArrayList<>(Math.max(tokens.size(), 32));
@@ -66,9 +61,6 @@ static QueryRenderer from(Collection<? extends QueryToken> tokens) {
 
 	/**
 	 * Creates a QueryRenderer from a {@link QueryTokenStream}.
-	 *
-	 * @param tokens
-	 * @return
 	 */
 	static QueryRenderer from(QueryTokenStream tokens) {
 
@@ -85,8 +77,6 @@ static QueryRenderer from(QueryTokenStream tokens) {
 
 	/**
 	 * Creates a new empty {@link QueryRenderer}.
-	 *
-	 * @return
 	 */
 	public static QueryRenderer empty() {
 		return EmptyQueryRenderer.INSTANCE;
@@ -94,8 +84,6 @@ public static QueryRenderer empty() {
 
 	/**
 	 * Creates a new {@link QueryRendererBuilder}.
-	 *
-	 * @return
 	 */
 	static QueryRendererBuilder builder() {
 		return new QueryRendererBuilder();
@@ -144,14 +132,11 @@ static String render(Iterable<QueryToken> tokenStream) {
 			results.append(token.value());
 		}
 
-		return results.toString();
+		return results != null ? results.toString() : "";
 	}
 
 	/**
 	 * Append a {@link QueryRenderer} to create a composed renderer.
-	 *
-	 * @param tokens
-	 * @return
 	 */
 	QueryRenderer append(QueryTokenStream tokens) {
 
@@ -180,7 +165,7 @@ public String toString() {
 		return render();
 	}
 
-	public static QueryRenderer expression(QueryTokenStream tokenStream) {
+	public static QueryRenderer ofExpression(QueryTokenStream tokenStream) {
 
 		if (tokenStream instanceof QueryRendererBuilder builder) {
 			tokenStream = builder.current;
@@ -258,9 +243,6 @@ String render() {
 
 		/**
 		 * Append a {@link QueryRenderer} to create a composed renderer.
-		 *
-		 * @param tokens
-		 * @return
 		 */
 		QueryRenderer append(QueryTokenStream tokens) {
 
@@ -290,7 +272,7 @@ QueryRenderer append(QueryTokenStream tokens) {
 		}
 
 		@Override
-		public QueryToken getLast() {
+		public @Nullable QueryToken getLast() {
 
 			for (int i = nested.size() - 1; i > -1; i--) {
 
@@ -386,12 +368,12 @@ public List<QueryToken> toList() {
 		}
 
 		@Override
-		public QueryToken getFirst() {
+		public @Nullable QueryToken getFirst() {
 			return tokens.isEmpty() ? null : tokens.get(0);
 		}
 
 		@Override
-		public QueryToken getLast() {
+		public @Nullable QueryToken getLast() {
 			return tokens.isEmpty() ? null : tokens.get(tokens.size() - 1);
 		}
 
@@ -407,7 +389,7 @@ public boolean isEmpty() {
 
 		@Override
 		public boolean isExpression() {
-			return !tokens.isEmpty() && getLast().isExpression();
+			return !tokens.isEmpty() && getRequiredLast().isExpression();
 		}
 
 		/**
@@ -418,7 +400,7 @@ public boolean isExpression() {
 		 */
 		static String render(Object tokens) {
 
-			if (tokens instanceof Collection tpr) {
+			if (tokens instanceof Collection<?> tpr) {
 				return render(tpr);
 			}
 
@@ -454,12 +436,12 @@ public Iterator<QueryToken> iterator() {
 		}
 
 		@Override
-		public QueryToken getFirst() {
+		public @Nullable QueryToken getFirst() {
 			return tokens.getFirst();
 		}
 
 		@Override
-		public QueryToken getLast() {
+		public @Nullable QueryToken getLast() {
 			return tokens.getLast();
 		}
 
@@ -475,7 +457,7 @@ public boolean isEmpty() {
 
 		@Override
 		public boolean isExpression() {
-			return !tokens.isEmpty() && tokens.getLast().isExpression();
+			return !tokens.isEmpty() && tokens.getRequiredLast().isExpression();
 		}
 	}
 
@@ -486,68 +468,13 @@ static class QueryRendererBuilder implements QueryTokenStream {
 
 		protected QueryRenderer current = QueryRenderer.empty();
 
-		/**
-		 * Compose a {@link QueryRendererBuilder} from a collection of inline elements that can be mapped to
-		 * {@link QueryRendererBuilder}.
-		 *
-		 * @param elements
-		 * @param visitor
-		 * @param separator
-		 * @return
-		 * @param <T>
-		 */
-		public static <T> QueryRendererBuilder concat(Collection<T> elements, Function<T, QueryRendererBuilder> visitor,
-				QueryToken separator) {
-			return concat(elements, visitor, QueryRendererBuilder::toInline, separator);
-		}
-
-		/**
-		 * Compose a {@link QueryRendererBuilder} from a collection of expression elements that can be mapped to
-		 * {@link QueryRendererBuilder}.
-		 *
-		 * @param elements
-		 * @param visitor
-		 * @param separator
-		 * @return
-		 * @param <T>
-		 */
-		public static <T> QueryRendererBuilder concatExpressions(Collection<T> elements,
-				Function<T, QueryRendererBuilder> visitor, QueryToken separator) {
-			return concat(elements, visitor, QueryRendererBuilder::toExpression, separator);
-		}
-
-		/**
-		 * Compose a {@link QueryRendererBuilder} from a collection of elements that can be mapped to
-		 * {@link QueryRendererBuilder}.
-		 *
-		 * @param elements
-		 * @param visitor
-		 * @param postProcess post-processing function to convert {@link QueryRendererBuilder} into {@link QueryRenderer}.
-		 * @param separator
-		 * @return
-		 * @param <T>
-		 */
-		public static <T> QueryRendererBuilder concat(Collection<T> elements, Function<T, QueryRendererBuilder> visitor,
-				Function<QueryRendererBuilder, QueryRenderer> postProcess, QueryToken separator) {
-
-			QueryRendererBuilder builder = new QueryRendererBuilder();
-			for (T element : elements) {
-				if (!builder.isEmpty()) {
-					builder.append(separator);
-				}
-				builder.append(postProcess.apply(visitor.apply(element)));
-			}
-
-			return builder;
-		}
-
 		/**
 		 * Create and initialize a QueryRendererBuilder from a {@link QueryTokens.SimpleQueryToken}.
 		 *
 		 * @param token
 		 * @return
 		 */
-		public static QueryRendererBuilder from(QueryToken token) {
+		public static QueryRendererBuilder builder(QueryToken token) {
 			return new QueryRendererBuilder().append(token);
 		}
 
@@ -627,7 +554,7 @@ QueryRendererBuilder appendExpression(QueryTokenStream tokens) {
 				return this;
 			}
 
-			current = current.append(QueryRenderer.expression(tokens));
+			current = current.append(QueryRenderer.ofExpression(tokens));
 
 			return this;
 		}
@@ -643,12 +570,12 @@ public Stream<QueryToken> stream() {
 		}
 
 		@Override
-		public QueryToken getFirst() {
+		public @Nullable QueryToken getFirst() {
 			return current.getFirst();
 		}
 
 		@Override
-		public QueryToken getLast() {
+		public @Nullable QueryToken getLast() {
 			return current.getLast();
 		}
 
@@ -657,11 +584,6 @@ public boolean isExpression() {
 			return current.isExpression();
 		}
 
-		/**
-		 * Return whet the builder is empty.
-		 *
-		 * @return
-		 */
 		@Override
 		public boolean isEmpty() {
 			return current.isEmpty();
@@ -686,19 +608,6 @@ public QueryRenderer build() {
 			return current;
 		}
 
-		QueryRenderer toExpression() {
-
-			if (current instanceof ExpressionRenderer) {
-				return current;
-			}
-
-			return QueryRenderer.expression(current);
-		}
-
-		public QueryRenderer toInline() {
-			return new InlineRenderer(current);
-		}
-
 	}
 
 	private static class InlineRenderer extends QueryRenderer {
@@ -730,12 +639,12 @@ public Iterator<QueryToken> iterator() {
 		}
 
 		@Override
-		public QueryToken getFirst() {
+		public @Nullable QueryToken getFirst() {
 			return delegate.getFirst();
 		}
 
 		@Override
-		public QueryToken getLast() {
+		public @Nullable QueryToken getLast() {
 			return delegate.getLast();
 		}
 
@@ -784,12 +693,12 @@ public Iterator<QueryToken> iterator() {
 		}
 
 		@Override
-		public QueryToken getFirst() {
+		public @Nullable QueryToken getFirst() {
 			return delegate.getFirst();
 		}
 
 		@Override
-		public QueryToken getLast() {
+		public @Nullable QueryToken getLast() {
 			return delegate.getLast();
 		}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokenStream.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokenStream.java
index c91fddb0e4..5b68191cfd 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokenStream.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokenStream.java
@@ -16,12 +16,16 @@
 package org.springframework.data.jpa.repository.query;
 
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Iterator;
+import java.util.NoSuchElementException;
 import java.util.function.Function;
 
-import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder;
+import org.antlr.v4.runtime.Token;
+import org.antlr.v4.runtime.tree.TerminalNode;
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.util.Streamable;
-import org.springframework.lang.Nullable;
 import org.springframework.util.CollectionUtils;
 
 /**
@@ -35,13 +39,35 @@ interface QueryTokenStream extends Streamable<QueryToken> {
 
 	/**
 	 * Creates an empty stream.
-	 *
-	 * @return
 	 */
 	static QueryTokenStream empty() {
 		return EmptyQueryTokenStream.INSTANCE;
 	}
 
+	/**
+	 * Creates a QueryTokenStream from a {@link QueryToken}.
+	 * @since 4.0
+	 */
+	static QueryTokenStream from(QueryToken token) {
+		return QueryRenderer.from(Collections.singletonList(token));
+	}
+
+	/**
+	 * Creates an token QueryRenderer from an AST {@link TerminalNode}.
+	 * @since 4.0
+	 */
+	static QueryTokenStream ofToken(TerminalNode node) {
+		return from(QueryTokens.token(node));
+	}
+
+	/**
+	 * Creates an token QueryRenderer from an AST {@link Token}.
+	 * @since 4.0
+	 */
+	static QueryTokenStream ofToken(Token node) {
+		return from(QueryTokens.token(node));
+	}
+
 	/**
 	 * Compose a {@link QueryTokenStream} from a collection of inline elements.
 	 *
@@ -55,10 +81,6 @@ static <T> QueryTokenStream concat(Collection<T> elements, Function<T, QueryToke
 		return concat(elements, visitor, QueryRenderer::inline, separator);
 	}
 
-	static <T> QueryTokenStream justAs(Collection<T> elements, Function<T, QueryToken> converter) {
-		return concat(elements, it-> QueryRendererBuilder.from(converter.apply(it)), QueryRenderer::inline, QueryTokens.TOKEN_SPACE);
-	}
-
 	/**
 	 * Compose a {@link QueryTokenStream} from a collection of expression elements.
 	 *
@@ -69,7 +91,7 @@ static <T> QueryTokenStream justAs(Collection<T> elements, Function<T, QueryToke
 	 */
 	static <T> QueryTokenStream concatExpressions(Collection<T> elements, Function<T, QueryTokenStream> visitor,
 			QueryToken separator) {
-		return concat(elements, visitor, QueryRenderer::expression, separator);
+		return concat(elements, visitor, QueryRenderer::ofExpression, separator);
 	}
 
 	/**
@@ -120,21 +142,49 @@ static <T> QueryTokenStream concat(Collection<T> elements, Function<T, QueryToke
 	/**
 	 * @return the first query token or {@code null} if empty.
 	 */
-	@Nullable
-	default QueryToken getFirst() {
+	default @Nullable QueryToken getFirst() {
 
 		Iterator<QueryToken> it = iterator();
 		return it.hasNext() ? it.next() : null;
 	}
 
+	/**
+	 * @return the required first query token or throw {@link java.util.NoSuchElementException} if empty.
+	 * @since 4.0
+	 */
+	default QueryToken getRequiredFirst() {
+
+		QueryToken first = getFirst();
+
+		if (first == null) {
+			throw new NoSuchElementException("No token in the stream");
+		}
+
+		return first;
+	}
+
 	/**
 	 * @return the last query token or {@code null} if empty.
 	 */
-	@Nullable
-	default QueryToken getLast() {
+	default @Nullable QueryToken getLast() {
 		return CollectionUtils.lastElement(toList());
 	}
 
+	/**
+	 * @return the required last query token or throw {@link java.util.NoSuchElementException} if empty.
+	 * @since 4.0
+	 */
+	default QueryToken getRequiredLast() {
+
+		QueryToken last = getLast();
+
+		if (last == null) {
+			throw new NoSuchElementException("No token in the stream");
+		}
+
+		return last;
+	}
+
 	/**
 	 * @return {@code true} if this stream represents a query expression.
 	 */
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java
index ea95343d42..0a60c39acd 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java
@@ -31,7 +31,7 @@ class QueryTokens {
 	/**
 	 * Commonly use tokens.
 	 */
-	static final QueryToken TOKEN_NONE = token("");
+	static final QueryToken EMPTY_TOKEN = token("");
 	static final QueryToken TOKEN_COMMA = token(", ");
 	static final QueryToken TOKEN_SPACE = token(" ");
 	static final QueryToken TOKEN_DOT = token(".");
@@ -58,15 +58,9 @@ class QueryTokens {
 	static final QueryToken TOKEN_WITH = expression("WITH");
 	static final QueryToken TOKEN_NOT = expression("NOT");
 	static final QueryToken TOKEN_MATERIALIZED = expression("materialized");
-	static final QueryToken TOKEN_NULLS = expression("NULLS");
-	static final QueryToken TOKEN_FIRST = expression("FIRST");
-	static final QueryToken TOKEN_LAST = expression("LAST");
 
 	/**
 	 * Creates a {@link QueryToken token} from an ANTLR {@link TerminalNode}.
-	 *
-	 * @param node
-	 * @return
 	 */
 	static QueryToken token(TerminalNode node) {
 		return token(node.getText());
@@ -74,9 +68,6 @@ static QueryToken token(TerminalNode node) {
 
 	/**
 	 * Creates a {@link QueryToken token} from an ANTLR {@link Token}.
-	 *
-	 * @param token
-	 * @return
 	 */
 	static QueryToken token(Token token) {
 		return token(token.getText());
@@ -84,9 +75,6 @@ static QueryToken token(Token token) {
 
 	/**
 	 * Creates a {@link QueryToken token} from a string {@code token}.
-	 *
-	 * @param token
-	 * @return
 	 */
 	static QueryToken token(String token) {
 		return new SimpleQueryToken(token);
@@ -94,9 +82,6 @@ static QueryToken token(String token) {
 
 	/**
 	 * Creates a ventilated token that is embedded in spaces.
-	 *
-	 * @param token
-	 * @return
 	 */
 	static QueryToken ventilated(Token token) {
 		return new SimpleQueryToken(" " + token.getText() + " ");
@@ -104,9 +89,6 @@ static QueryToken ventilated(Token token) {
 
 	/**
 	 * Creates a {@link QueryToken expression} from an ANTLR {@link TerminalNode}.
-	 *
-	 * @param node
-	 * @return
 	 */
 	static QueryToken expression(TerminalNode node) {
 		return expression(node.getText());
@@ -114,9 +96,6 @@ static QueryToken expression(TerminalNode node) {
 
 	/**
 	 * Creates a {@link QueryToken expression} from an ANTLR {@link Token}.
-	 *
-	 * @param token
-	 * @return
 	 */
 	static QueryToken expression(Token token) {
 		return expression(token.getText());
@@ -124,9 +103,6 @@ static QueryToken expression(Token token) {
 
 	/**
 	 * Creates a {@link QueryToken token} from a string {@code expression}.
-	 *
-	 * @param expression
-	 * @return
 	 */
 	static QueryToken expression(String expression) {
 		return new ExpressionToken(expression);
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
index 371dc0b6cc..749853f36f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java
@@ -29,6 +29,7 @@
 import jakarta.persistence.criteria.From;
 import jakarta.persistence.criteria.Join;
 import jakarta.persistence.criteria.JoinType;
+import jakarta.persistence.criteria.Nulls;
 import jakarta.persistence.criteria.Path;
 import jakarta.persistence.metamodel.Attribute;
 import jakarta.persistence.metamodel.Attribute.PersistentAttributeType;
@@ -45,6 +46,8 @@
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.core.annotation.AnnotationUtils;
 import org.springframework.dao.InvalidDataAccessApiUsageException;
 import org.springframework.data.domain.Sort;
@@ -52,7 +55,6 @@
 import org.springframework.data.jpa.domain.JpaSort.JpaOrder;
 import org.springframework.data.mapping.PropertyPath;
 import org.springframework.data.util.Streamable;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 
@@ -130,7 +132,7 @@ public abstract class QueryUtils {
 
 	private static final Pattern CONSTRUCTOR_EXPRESSION;
 
-	private static final Map<PersistentAttributeType, Class<? extends Annotation>> ASSOCIATION_TYPES;
+	static final Map<PersistentAttributeType, Class<? extends Annotation>> ASSOCIATION_TYPES;
 
 	private static final int QUERY_JOIN_ALIAS_GROUP_INDEX = 3;
 	private static final int VARIABLE_NAME_GROUP_INDEX = 4;
@@ -443,11 +445,8 @@ private static String toJpaDirection(Order order) {
 	 *
 	 * @param query must not be {@literal null}.
 	 * @return Might return {@literal null}.
-	 * @deprecated use {@link DeclaredQuery#getAlias()} instead.
 	 */
-	@Nullable
-	@Deprecated
-	public static String detectAlias(String query) {
+	static @Nullable String detectAlias(String query) {
 
 		String alias = null;
 		Matcher matcher = ALIAS_MATCH.matcher(removeSubqueries(query));
@@ -553,10 +552,8 @@ public static <T> Query applyAndBind(String queryString, Iterable<T> entities, E
 	 *
 	 * @param originalQuery must not be {@literal null} or empty.
 	 * @return Guaranteed to be not {@literal null}.
-	 * @deprecated use {@link DeclaredQuery#deriveCountQuery(String)} instead.
 	 */
-	@Deprecated
-	public static String createCountQueryFor(String originalQuery) {
+	static String createCountQueryFor(String originalQuery) {
 		return createCountQueryFor(originalQuery, null);
 	}
 
@@ -567,10 +564,8 @@ public static String createCountQueryFor(String originalQuery) {
 	 * @param countProjection may be {@literal null}.
 	 * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}.
 	 * @since 1.6
-	 * @deprecated use {@link DeclaredQuery#deriveCountQuery(String)} instead.
 	 */
-	@Deprecated
-	public static String createCountQueryFor(String originalQuery, @Nullable String countProjection) {
+	static String createCountQueryFor(String originalQuery, @Nullable String countProjection) {
 		return createCountQueryFor(originalQuery, countProjection, false);
 	}
 
@@ -583,7 +578,7 @@ public static String createCountQueryFor(String originalQuery, @Nullable String
 	 * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}.
 	 * @since 2.7.8
 	 */
-	static String createCountQueryFor(String originalQuery, @Nullable String countProjection, boolean nativeQuery) {
+	public static String createCountQueryFor(String originalQuery, @Nullable String countProjection, boolean nativeQuery) {
 
 		Assert.hasText(originalQuery, "OriginalQuery must not be null or empty");
 
@@ -724,21 +719,35 @@ public static String getProjection(String query) {
 	@SuppressWarnings("unchecked")
 	private static jakarta.persistence.criteria.Order toJpaOrder(Order order, From<?, ?> from, CriteriaBuilder cb) {
 
-		PropertyPath property = PropertyPath.from(order.getProperty(), from.getJavaType());
-		Expression<?> expression = toExpressionRecursively(from, property);
+		Expression<?> expression;
 
-		if (order.getNullHandling() != Sort.NullHandling.NATIVE) {
-			throw new UnsupportedOperationException("Applying Null Precedence using Criteria Queries is not yet supported.");
+		if (order instanceof JpaOrder jpaOrder && jpaOrder.isUnsafe()) {
+			expression = new HqlOrderExpressionVisitor(cb, from, QueryUtils::toExpressionRecursively)
+					.createCriteriaExpression(order);
+		} else {
+			PropertyPath property = PropertyPath.from(order.getProperty(), from.getJavaType());
+			expression = toExpressionRecursively(from, property);
 		}
 
+		Nulls nulls = toNulls(order.getNullHandling());
+
 		if (order.isIgnoreCase() && String.class.equals(expression.getJavaType())) {
 			Expression<String> upper = cb.lower((Expression<String>) expression);
-			return order.isAscending() ? cb.asc(upper) : cb.desc(upper);
+			return order.isAscending() ? cb.asc(upper, nulls) : cb.desc(upper, nulls);
 		} else {
-			return order.isAscending() ? cb.asc(expression) : cb.desc(expression);
+			return order.isAscending() ? cb.asc(expression, nulls) : cb.desc(expression, nulls);
 		}
 	}
 
+	private static Nulls toNulls(Sort.NullHandling nullHandling) {
+
+		return switch (nullHandling) {
+			case NULLS_LAST -> Nulls.LAST;
+			case NULLS_FIRST -> Nulls.FIRST;
+			case NATIVE -> Nulls.NONE;
+		};
+	}
+
 	static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath property) {
 		return toExpressionRecursively(from, property, false);
 	}
@@ -801,7 +810,7 @@ static <T> Expression<T> toExpressionRecursively(From<?, ?> from, PropertyPath p
 	 * @param hasRequiredOuterJoin has a parent already required an outer join?
 	 * @return whether an outer join is to be used for integrating this attribute in a query.
 	 */
-	private static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property, boolean isForSelection,
+	static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property, boolean isForSelection,
 			boolean hasRequiredOuterJoin) {
 
 		// already inner joined so outer join is useless
@@ -844,8 +853,8 @@ private static boolean requiresOuterJoin(From<?, ?> from, PropertyPath property,
 		return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true);
 	}
 
-	@Nullable
-	private static <T> T getAnnotationProperty(Attribute<?, ?> attribute, String propertyName, T defaultValue) {
+	@SuppressWarnings("unchecked")
+	static <T> T getAnnotationProperty(Attribute<?, ?> attribute, String propertyName, T defaultValue) {
 
 		Class<? extends Annotation> associationAnnotation = ASSOCIATION_TYPES.get(attribute.getPersistentAttributeType());
 
@@ -860,7 +869,12 @@ private static <T> T getAnnotationProperty(Attribute<?, ?> attribute, String pro
 		}
 
 		Annotation annotation = AnnotationUtils.getAnnotation(annotatedMember, associationAnnotation);
-		return annotation == null ? defaultValue : (T) AnnotationUtils.getValue(annotation, propertyName);
+		if (annotation == null) {
+			return defaultValue;
+		}
+
+		T value = (T) AnnotationUtils.getValue(annotation, propertyName);
+		return value != null ? value : defaultValue;
 	}
 
 	/**
@@ -871,7 +885,7 @@ private static <T> T getAnnotationProperty(Attribute<?, ?> attribute, String pro
 	 * @param joinType the join type to create if none was found
 	 * @return will never be {@literal null}.
 	 */
-	private static Join<?, ?> getOrCreateJoin(From<?, ?> from, String attribute, JoinType joinType) {
+	static Join<?, ?> getOrCreateJoin(From<?, ?> from, String attribute, JoinType joinType) {
 
 		for (Fetch<?, ?> fetch : from.getFetches()) {
 
@@ -896,7 +910,7 @@ private static <T> T getAnnotationProperty(Attribute<?, ?> attribute, String pro
 	 * @param attribute the attribute name to check.
 	 * @return true if the attribute has already been inner joined
 	 */
-	private static boolean isAlreadyInnerJoined(From<?, ?> from, String attribute) {
+	static boolean isAlreadyInnerJoined(From<?, ?> from, String attribute) {
 
 		for (Fetch<?, ?> fetch : from.getFetches()) {
 
@@ -948,8 +962,7 @@ static void checkSortExpression(Order order) {
 	 * @see <a href=
 	 *      "https://github.com/jakartaee/persistence/issues/562">https://github.com/jakartaee/persistence/issues/562</a>
 	 */
-	@Nullable
-	private static Bindable<?> getModelForPath(PropertyPath path, @Nullable ManagedType<?> managedType,
+	private static @Nullable Bindable<?> getModelForPath(PropertyPath path, @Nullable ManagedType<?> managedType,
 			Path<?> fallback) {
 
 		String segment = path.getSegment();
@@ -973,8 +986,7 @@ private static Bindable<?> getModelForPath(PropertyPath path, @Nullable ManagedT
 	 * @param model
 	 * @return
 	 */
-	@Nullable
-	private static ManagedType<?> getManagedTypeForModel(Bindable<?> model) {
+	static @Nullable ManagedType<?> getManagedTypeForModel(Bindable<?> model) {
 
 		if (model instanceof ManagedType<?> managedType) {
 			return managedType;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/SimpleJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/SimpleJpaQuery.java
index c9a80e4a38..b042318b13 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/SimpleJpaQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/SimpleJpaQuery.java
@@ -18,10 +18,9 @@
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.Query;
 
-import org.springframework.data.jpa.repository.QueryRewriter;
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.repository.query.RepositoryQuery;
-import org.springframework.data.repository.query.ValueExpressionDelegate;
-import org.springframework.lang.Nullable;
 
 /**
  * {@link RepositoryQuery} implementation that inspects a {@link org.springframework.data.repository.query.QueryMethod}
@@ -33,36 +32,21 @@
  * @author Mark Paluch
  * @author Greg Turnquist
  */
-final class SimpleJpaQuery extends AbstractStringBasedJpaQuery {
-
-	/**
-	 * Creates a new {@link SimpleJpaQuery} encapsulating the query annotated on the given {@link JpaQueryMethod}.
-	 *
-	 * @param method must not be {@literal null}
-	 * @param em must not be {@literal null}
-	 * @param countQueryString
-	 * @param queryRewriter must not be {@literal null}
-	 * @param valueExpressionDelegate must not be {@literal null}
-	 */
-	public SimpleJpaQuery(JpaQueryMethod method, EntityManager em, @Nullable String countQueryString,
-			QueryRewriter queryRewriter, ValueExpressionDelegate valueExpressionDelegate) {
-		this(method, em, method.getRequiredAnnotatedQuery(), countQueryString, queryRewriter, valueExpressionDelegate);
-	}
+class SimpleJpaQuery extends AbstractStringBasedJpaQuery {
 
 	/**
 	 * Creates a new {@link SimpleJpaQuery} that encapsulates a simple query string.
 	 *
-	 * @param method must not be {@literal null}
-	 * @param em must not be {@literal null}
-	 * @param queryString must not be {@literal null} or empty
-	 * @param countQueryString
-	 * @param queryRewriter
-	 * @param valueExpressionDelegate must not be {@literal null}
+	 * @param method must not be {@literal null}.
+	 * @param em must not be {@literal null}.
+	 * @param query must not be {@literal null} or empty.
+	 * @param countQuery can be {@literal null} if not defined.
+	 * @param queryConfiguration must not be {@literal null}.
 	 */
-	public SimpleJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, @Nullable String countQueryString, QueryRewriter queryRewriter,
-			ValueExpressionDelegate valueExpressionDelegate) {
+	public SimpleJpaQuery(JpaQueryMethod method, EntityManager em, DeclaredQuery query,
+			@Nullable DeclaredQuery countQuery, JpaQueryConfiguration queryConfiguration) {
 
-		super(method, em, queryString, countQueryString, queryRewriter, valueExpressionDelegate);
+		super(method, em, query, countQuery, queryConfiguration);
 
 		validateQuery(getQuery().getQueryString(), "Validation failed for query for method %s", method);
 
@@ -84,23 +68,13 @@ private void validateQuery(String query, String errorMessage, Object... argument
 			return;
 		}
 
-		EntityManager validatingEm = null;
+        try (EntityManager validatingEm = getEntityManager().getEntityManagerFactory().createEntityManager()) {
+            validatingEm.createQuery(query);
+        } catch (RuntimeException e) {
 
-		try {
-			validatingEm = getEntityManager().getEntityManagerFactory().createEntityManager();
-			validatingEm.createQuery(query);
-
-		} catch (RuntimeException e) {
-
-			// Needed as there's ambiguities in how an invalid query string shall be expressed by the persistence provider
-			// https://java.net/projects/jpa-spec/lists/jsr338-experts/archive/2012-07/message/17
-			throw new IllegalArgumentException(String.format(errorMessage, arguments), e);
-
-		} finally {
-
-			if (validatingEm != null) {
-				validatingEm.close();
-			}
-		}
+            // Needed as there's ambiguities in how an invalid query string shall be expressed by the persistence provider
+            // https://java.net/projects/jpa-spec/lists/jsr338-experts/archive/2012-07/message/17
+            throw new IllegalArgumentException(String.format(errorMessage, arguments), e);
+        }
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributeSource.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributeSource.java
index 2616c3d796..2463b64c6a 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributeSource.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributeSource.java
@@ -27,7 +27,8 @@
 import java.util.List;
 
 import org.springframework.core.annotation.AnnotatedElementUtils;
-import org.springframework.lang.Nullable;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.ObjectUtils;
 import org.springframework.util.StringUtils;
@@ -174,8 +175,7 @@ private List<ProcedureParameter> extractOutputParametersFrom(NamedStoredProcedur
 	 * @param procedure must not be {@literal null}.
 	 * @return
 	 */
-	@Nullable
-	private NamedStoredProcedureQuery tryFindAnnotatedNamedStoredProcedureQuery(Method method,
+	private @Nullable NamedStoredProcedureQuery tryFindAnnotatedNamedStoredProcedureQuery(Method method,
 			JpaEntityMetadata<?> entityMetadata, Procedure procedure) {
 
 		Assert.notNull(method, "Method must not be null");
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributes.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributes.java
index e7ef76a3eb..0429ac5f6f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributes.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributes.java
@@ -22,6 +22,7 @@
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 
+import org.jspecify.annotations.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
 import org.springframework.util.StringUtils;
@@ -93,7 +94,7 @@ private ProcedureParameter getParameterWithCompletedName(ProcedureParameter para
 				parameter.getType());
 	}
 
-	private String completeOutputParameterName(int i, String paramName) {
+	private String completeOutputParameterName(int i, @Nullable String paramName) {
 
 		return StringUtils.hasText(paramName) //
 				? paramName //
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java
index 8ff29f4ba2..caece33d0f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java
@@ -26,9 +26,10 @@
 import java.util.Map;
 
 import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.repository.query.Parameter;
 import org.springframework.data.repository.query.QueryMethod;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.StringUtils;
 
@@ -50,7 +51,6 @@ class StoredProcedureJpaQuery extends AbstractJpaQuery {
 
 	private final StoredProcedureAttributes procedureAttributes;
 	private final boolean useNamedParameters;
-	private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache();
 
 	/**
 	 * Creates a new {@link StoredProcedureJpaQuery}.
@@ -81,6 +81,11 @@ private static boolean useNamedParameters(QueryMethod method) {
 		return false;
 	}
 
+	@Override
+	public boolean hasDeclaredCountQuery() {
+		return false;
+	}
+
 	@Override
 	protected StoredProcedureQuery createQuery(JpaParametersParameterAccessor accessor) {
 		return applyHints(doCreateQuery(accessor), getQueryMethod());
@@ -90,9 +95,7 @@ protected StoredProcedureQuery createQuery(JpaParametersParameterAccessor access
 	protected StoredProcedureQuery doCreateQuery(JpaParametersParameterAccessor accessor) {
 
 		StoredProcedureQuery storedProcedure = createStoredProcedure();
-		QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata("singleton", storedProcedure);
-
-		return parameterBinder.get().bind(storedProcedure, metadata, accessor);
+		return parameterBinder.get().bind(storedProcedure, accessor);
 	}
 
 	@Override
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/TemplatedQuery.java
similarity index 59%
rename from spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java
rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/TemplatedQuery.java
index 3007f494ca..487a7b11f8 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/TemplatedQuery.java
@@ -18,17 +18,18 @@
 import java.util.Objects;
 import java.util.regex.Pattern;
 
+import org.springframework.core.env.Environment;
+import org.springframework.core.env.StandardEnvironment;
 import org.springframework.data.expression.ValueEvaluationContext;
 import org.springframework.data.expression.ValueExpression;
 import org.springframework.data.expression.ValueExpressionParser;
-import org.springframework.data.repository.core.EntityMetadata;
-import org.springframework.expression.spel.support.StandardEvaluationContext;
+import org.springframework.expression.spel.support.SimpleEvaluationContext;
 import org.springframework.util.Assert;
 
 /**
- * Extension of {@link StringQuery} that evaluates the given query string as a SpEL template-expression.
+ * Factory methods to obtain {@link EntityQuery} from a declared query using SpEL template-expressions.
  * <p>
- * Currently the following template variables are available:
+ * Currently, the following template variables are available:
  * <ol>
  * <li>{@code #entityName} - the simple class name of the given entity</li>
  * <ol>
@@ -40,7 +41,7 @@
  * @author Diego Krupitza
  * @author Greg Turnquist
  */
-class ExpressionBasedStringQuery extends StringQuery {
+class TemplatedQuery {
 
 	private static final String EXPRESSION_PARAMETER = "$1#{";
 	private static final String QUOTED_EXPRESSION_PARAMETER = "$1__HASH__{";
@@ -52,31 +53,42 @@ class ExpressionBasedStringQuery extends StringQuery {
 	private static final String ENTITY_NAME_VARIABLE = "#" + ENTITY_NAME;
 	private static final String ENTITY_NAME_VARIABLE_EXPRESSION = "#{" + ENTITY_NAME_VARIABLE;
 
+	private static final Environment DEFAULT_ENVIRONMENT;
+
+	static {
+		DEFAULT_ENVIRONMENT = new StandardEnvironment();
+	}
+
 	/**
-	 * Creates a new {@link ExpressionBasedStringQuery} for the given query and {@link EntityMetadata}.
+	 * Create a {@link DefaultEntityQuery} given {@link String query}, {@link JpaQueryMethod} and
+	 * {@link JpaQueryConfiguration}.
 	 *
-	 * @param query must not be {@literal null} or empty.
-	 * @param metadata must not be {@literal null}.
-	 * @param parser must not be {@literal null}.
-	 * @param nativeQuery is a given query is native or not
+	 * @param queryString must not be {@literal null}.
+	 * @param queryMethod must not be {@literal null}.
+	 * @param queryContext must not be {@literal null}.
+	 * @return the created {@link DefaultEntityQuery}.
 	 */
-	public ExpressionBasedStringQuery(String query, JpaEntityMetadata<?> metadata, ValueExpressionParser parser,
-			boolean nativeQuery) {
-		super(renderQueryIfExpressionOrReturnQuery(query, metadata, parser), nativeQuery && !containsExpression(query));
+	public static EntityQuery create(String queryString, JpaQueryMethod queryMethod, JpaQueryConfiguration queryContext) {
+		return create(queryMethod.getDeclaredQuery(queryString), queryMethod.getEntityInformation(), queryContext);
 	}
 
 	/**
-	 * Creates an {@link ExpressionBasedStringQuery} from a given {@link DeclaredQuery}.
+	 * Create a {@link DefaultEntityQuery} given {@link DeclaredQuery query}, {@link JpaEntityMetadata} and
+	 * {@link JpaQueryConfiguration}.
 	 *
-	 * @param query the original query. Must not be {@literal null}.
-	 * @param metadata the {@link JpaEntityMetadata} for the given entity. Must not be {@literal null}.
-	 * @param parser Parser for resolving SpEL expressions. Must not be {@literal null}.
-	 * @param nativeQuery is a given query native or not
-	 * @return A query supporting SpEL expressions.
+	 * @param declaredQuery must not be {@literal null}.
+	 * @param entityMetadata must not be {@literal null}.
+	 * @param queryContext must not be {@literal null}.
+	 * @return the created {@link DefaultEntityQuery}.
 	 */
-	static ExpressionBasedStringQuery from(DeclaredQuery query, JpaEntityMetadata<?> metadata,
-			ValueExpressionParser parser, boolean nativeQuery) {
-		return new ExpressionBasedStringQuery(query.getQueryString(), metadata, parser, nativeQuery);
+	public static EntityQuery create(DeclaredQuery declaredQuery, JpaEntityMetadata<?> entityMetadata,
+			JpaQueryConfiguration queryContext) {
+
+		ValueExpressionParser expressionParser = queryContext.getValueExpressionDelegate().getValueExpressionParser();
+		String resolvedExpressionQuery = renderQueryIfExpressionOrReturnQuery(declaredQuery.getQueryString(),
+				entityMetadata, expressionParser);
+
+		return EntityQuery.create(declaredQuery.rewrite(resolvedExpressionQuery), queryContext.getSelector());
 	}
 
 	/**
@@ -84,7 +96,7 @@ static ExpressionBasedStringQuery from(DeclaredQuery query, JpaEntityMetadata<?>
 	 * @param metadata the {@link JpaEntityMetadata} for the given entity. Must not be {@literal null}.
 	 * @param parser Must not be {@literal null}.
 	 */
-	private static String renderQueryIfExpressionOrReturnQuery(String query, JpaEntityMetadata<?> metadata,
+	static String renderQueryIfExpressionOrReturnQuery(String query, JpaEntityMetadata<?> metadata,
 			ValueExpressionParser parser) {
 
 		Assert.notNull(query, "query must not be null");
@@ -95,14 +107,14 @@ private static String renderQueryIfExpressionOrReturnQuery(String query, JpaEnti
 			return query;
 		}
 
-		StandardEvaluationContext evalContext = new StandardEvaluationContext();
+		SimpleEvaluationContext evalContext = SimpleEvaluationContext.forReadOnlyDataBinding().build();
 		evalContext.setVariable(ENTITY_NAME, metadata.getEntityName());
 
 		query = potentiallyQuoteExpressionsParameter(query);
 
 		ValueExpression expr = parser.parse(query);
 
-		String result = Objects.toString(expr.evaluate(ValueEvaluationContext.of(null, evalContext)));
+		String result = Objects.toString(expr.evaluate(ValueEvaluationContext.of(DEFAULT_ENVIRONMENT, evalContext)));
 
 		if (result == null) {
 			return query;
@@ -122,4 +134,5 @@ private static String potentiallyQuoteExpressionsParameter(String query) {
 	private static boolean containsExpression(String query) {
 		return query.contains(ENTITY_NAME_VARIABLE_EXPRESSION);
 	}
+
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/package-info.java
index efbf2d7af3..9f42b926da 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Query implementation to execute queries against JPA.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.repository.query;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java
index 4b0b7bacaf..6ac031cc56 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java
@@ -21,7 +21,8 @@
 import java.util.Optional;
 
 import org.springframework.data.jpa.repository.EntityGraph;
-import org.springframework.lang.Nullable;
+
+import org.jspecify.annotations.Nullable;
 
 /**
  * Interface to abstract {@link CrudMethodMetadata} that provide the {@link LockModeType} to be used for query
@@ -76,7 +77,8 @@ public interface CrudMethodMetadata {
 	 * @return
 	 * @since 1.9
 	 */
-	Optional<EntityGraph> getEntityGraph();
+	@Nullable
+	EntityGraph getEntityGraph();
 
 	/**
 	 * Returns the {@link Method} to be used.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPostProcessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPostProcessor.java
index 135d3c6e44..0a9a902e00 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPostProcessor.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPostProcessor.java
@@ -28,6 +28,7 @@
 
 import org.aopalliance.intercept.MethodInterceptor;
 import org.aopalliance.intercept.MethodInvocation;
+import org.jspecify.annotations.Nullable;
 
 import org.springframework.aop.TargetSource;
 import org.springframework.aop.framework.ProxyFactory;
@@ -41,7 +42,6 @@
 import org.springframework.data.jpa.repository.QueryHints;
 import org.springframework.data.repository.core.RepositoryInformation;
 import org.springframework.data.repository.core.support.RepositoryProxyPostProcessor;
-import org.springframework.lang.Nullable;
 import org.springframework.transaction.support.TransactionSynchronizationManager;
 import org.springframework.util.Assert;
 import org.springframework.util.ClassUtils;
@@ -61,11 +61,12 @@
  */
 class CrudMethodMetadataPostProcessor implements RepositoryProxyPostProcessor, BeanClassLoaderAware {
 
-	private @Nullable ClassLoader classLoader = ClassUtils.getDefaultClassLoader();
+	private @Nullable ClassLoader classLoader;
 
 	@Override
-	public void setBeanClassLoader(ClassLoader classLoader) {
-		this.classLoader = classLoader;
+	public void setBeanClassLoader(@Nullable ClassLoader classLoader) {
+		this.classLoader =  classLoader != null ? classLoader : ClassUtils.getDefaultClassLoader();
+
 	}
 
 	@Override
@@ -120,15 +121,16 @@ static MethodInvocation currentInvocation() throws IllegalStateException {
 
 			MethodInvocation mi = currentInvocation.get();
 
-			if (mi == null)
-				throw new IllegalStateException(
-						"No MethodInvocation found: Check that an AOP invocation is in progress, and that the "
-								+ "CrudMethodMetadataPopulatingMethodInterceptor is upfront in the interceptor chain.");
-			return mi;
+			if (mi != null) {
+				return mi;
+			}
+			throw new IllegalStateException(
+				"No MethodInvocation found: Check that an AOP invocation is in progress, and that the "
+					+ "CrudMethodMetadataPopulatingMethodInterceptor is upfront in the interceptor chain.");
 		}
 
 		@Override
-		public Object invoke(MethodInvocation invocation) throws Throwable {
+		public @Nullable Object invoke(MethodInvocation invocation) throws Throwable {
 
 			Method method = invocation.getMethod();
 
@@ -184,7 +186,7 @@ private static class DefaultCrudMethodMetadata implements CrudMethodMetadata {
 		private final org.springframework.data.jpa.repository.support.QueryHints queryHints;
 		private final org.springframework.data.jpa.repository.support.QueryHints queryHintsForCount;
 		private final @Nullable String comment;
-		private final Optional<EntityGraph> entityGraph;
+		private final @Nullable EntityGraph entityGraph;
 		private final Method method;
 
 		/**
@@ -204,12 +206,11 @@ private static class DefaultCrudMethodMetadata implements CrudMethodMetadata {
 			this.method = method;
 		}
 
-		private static Optional<EntityGraph> findEntityGraph(Method method) {
-			return Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(method, EntityGraph.class));
+		private static @Nullable EntityGraph findEntityGraph(Method method) {
+			return AnnotatedElementUtils.findMergedAnnotation(method, EntityGraph.class);
 		}
 
-		@Nullable
-		private static LockModeType findLockModeType(Method method) {
+		private static @Nullable LockModeType findLockModeType(Method method) {
 
 			Lock annotation = AnnotatedElementUtils.findMergedAnnotation(method, Lock.class);
 			return annotation == null ? null : (LockModeType) AnnotationUtils.getValue(annotation);
@@ -238,16 +239,14 @@ private static org.springframework.data.jpa.repository.support.QueryHints findQu
 			return queryHints;
 		}
 
-		@Nullable
-		private static String findComment(Method method) {
+		private static @Nullable String findComment(Method method) {
 
 			Meta annotation = AnnotatedElementUtils.findMergedAnnotation(method, Meta.class);
 			return annotation == null ? null : (String) AnnotationUtils.getValue(annotation, "comment");
 		}
 
-		@Nullable
 		@Override
-		public LockModeType getLockModeType() {
+		public @Nullable LockModeType getLockModeType() {
 			return lockModeType;
 		}
 
@@ -262,12 +261,12 @@ public org.springframework.data.jpa.repository.support.QueryHints getQueryHintsF
 		}
 
 		@Override
-		public String getComment() {
+		public @Nullable String getComment() {
 			return comment;
 		}
 
 		@Override
-		public Optional<EntityGraph> getEntityGraph() {
+		public @Nullable EntityGraph getEntityGraph() {
 			return entityGraph;
 		}
 
@@ -291,7 +290,7 @@ public boolean isStatic() {
 		}
 
 		@Override
-		public Object getTarget() {
+		public @Nullable Object getTarget() {
 
 			MethodInvocation invocation = CrudMethodMetadataPopulatingMethodInterceptor.currentInvocation();
 			return TransactionSynchronizationManager.getResource(invocation.getMethod());
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/DefaultQueryHints.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/DefaultQueryHints.java
index 228251d4f2..12c05b6e76 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/DefaultQueryHints.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/DefaultQueryHints.java
@@ -17,13 +17,12 @@
 
 import jakarta.persistence.EntityManager;
 
-import java.util.Optional;
 import java.util.function.BiConsumer;
 
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.EntityGraph;
 import org.springframework.data.jpa.repository.query.Jpa21Utils;
 import org.springframework.data.jpa.repository.query.JpaEntityGraph;
-import org.springframework.data.util.Optionals;
 import org.springframework.util.Assert;
 
 /**
@@ -38,7 +37,7 @@ class DefaultQueryHints implements QueryHints {
 
 	private final JpaEntityInformation<?, ?> information;
 	private final CrudMethodMetadata metadata;
-	private final Optional<EntityManager> entityManager;
+	private final @Nullable EntityManager entityManager;
 	private final boolean forCounts;
 
 	/**
@@ -46,12 +45,12 @@ class DefaultQueryHints implements QueryHints {
 	 * {@link CrudMethodMetadata}, {@link EntityManager} and whether to include fetch graphs.
 	 *
 	 * @param information must not be {@literal null}.
-	 * @param metadata must not be {@literal null}.
+	 * @param metadata can be {@literal null}.
 	 * @param entityManager must not be {@literal null}.
 	 * @param forCounts
 	 */
 	private DefaultQueryHints(JpaEntityInformation<?, ?> information, CrudMethodMetadata metadata,
-			Optional<EntityManager> entityManager, boolean forCounts) {
+			@Nullable EntityManager entityManager, boolean forCounts) {
 
 		this.information = information;
 		this.metadata = metadata;
@@ -72,12 +71,12 @@ public static QueryHints of(JpaEntityInformation<?, ?> information, CrudMethodMe
 		Assert.notNull(information, "JpaEntityInformation must not be null");
 		Assert.notNull(metadata, "CrudMethodMetadata must not be null");
 
-		return new DefaultQueryHints(information, metadata, Optional.empty(), false);
+		return new DefaultQueryHints(information, metadata, null, false);
 	}
 
 	@Override
 	public QueryHints withFetchGraphs(EntityManager em) {
-		return new DefaultQueryHints(this.information, this.metadata, Optional.of(em), this.forCounts);
+		return new DefaultQueryHints(this.information, this.metadata, em, this.forCounts);
 	}
 
 	@Override
@@ -96,10 +95,10 @@ private QueryHints combineHints() {
 
 	private QueryHints getFetchGraphs() {
 
-		return Optionals
-				.mapIfAllPresent(entityManager, metadata.getEntityGraph(),
-						(em, graph) -> Jpa21Utils.getFetchGraphHint(em, getEntityGraph(graph), information.getJavaType()))
-				.orElseGet(MutableQueryHints::new);
+		if(entityManager != null && metadata.getEntityGraph() != null) {
+			return Jpa21Utils.getFetchGraphHint(entityManager, getEntityGraph(metadata.getEntityGraph()), information.getJavaType());
+		}
+		return new MutableQueryHints();
 	}
 
 	private JpaEntityGraph getEntityGraph(EntityGraph entityGraph) {
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityGraphFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityGraphFactory.java
index 5308fa64b8..266bd3e003 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityGraphFactory.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityGraphFactory.java
@@ -61,10 +61,15 @@ public static <T> EntityGraph<T> create(EntityManager entityManager, Class<T> do
 				currentFullPath += path.getSegment() + ".";
 
 				if (path.hasNext()) {
-					final Subgraph<Object> finalCurrent = current;
-					current = current == null
-							? existingSubgraphs.computeIfAbsent(currentFullPath, k -> entityGraph.addSubgraph(path.getSegment()))
-							: existingSubgraphs.computeIfAbsent(currentFullPath, k -> finalCurrent.addSubgraph(path.getSegment()));
+
+					if (current == null) {
+						current = existingSubgraphs.computeIfAbsent(currentFullPath,
+								k -> entityGraph.addSubgraph(path.getSegment()));
+					} else {
+						final Subgraph<Object> finalCurrent = current;
+						current = existingSubgraphs.computeIfAbsent(currentFullPath,
+								k -> finalCurrent.addSubgraph(path.getSegment()));
+					}
 					continue;
 				}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java
index f5d42e2257..171e59012e 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java
@@ -41,7 +41,6 @@
 import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.support.PageableExecutionUtils;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 import com.querydsl.core.types.EntityPath;
@@ -52,6 +51,7 @@
 import com.querydsl.core.types.dsl.PathBuilder;
 import com.querydsl.jpa.JPQLSerializer;
 import com.querydsl.jpa.impl.AbstractJPAQuery;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Immutable implementation of {@link FetchableFluentQuery} based on a Querydsl {@link Predicate}. All methods that
@@ -147,7 +147,7 @@ public FetchableFluentQuery<R> project(Collection<String> properties) {
 	}
 
 	@Override
-	public R oneValue() {
+	public @Nullable R oneValue() {
 
 		List<?> results = createSortedAndProjectedQuery(this.sort) //
 				.limit(2) // Never need more than 2 values
@@ -161,7 +161,7 @@ public R oneValue() {
 	}
 
 	@Override
-	public R firstValue() {
+	public @Nullable R firstValue() {
 
 		List<?> results = createSortedAndProjectedQuery(this.sort) //
 				.limit(1) // Never need more than 1 value
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java
index 5d87904ec3..0b21210ff9 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java
@@ -26,6 +26,8 @@
 import java.util.function.Function;
 import java.util.stream.Stream;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.dao.IncorrectResultSizeDataAccessException;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageImpl;
@@ -42,7 +44,6 @@
 import org.springframework.data.projection.ProjectionFactory;
 import org.springframework.data.repository.query.FluentQuery;
 import org.springframework.data.support.PageableExecutionUtils;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
@@ -132,7 +133,7 @@ public SpecificationFluentQuery<R> project(Collection<String> properties) {
 	}
 
 	@Override
-	public R oneValue() {
+	public @Nullable R oneValue() {
 
 		List<?> results = createSortedAndProjectedQuery(this.sort) //
 				.setMaxResults(2) // Never need more than 2 values
@@ -146,7 +147,7 @@ public R oneValue() {
 	}
 
 	@Override
-	public R firstValue() {
+	public @Nullable R firstValue() {
 
 		List<?> results = createSortedAndProjectedQuery(this.sort) //
 				.setMaxResults(1) // Never need more than 1 value
@@ -174,18 +175,18 @@ public Window<R> scroll(ScrollPosition scrollPosition) {
 
 	@Override
 	public Slice<R> slice(Pageable pageable) {
-		return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSortOr(this.sort))) : readSlice(pageable);
+		return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSort())) : readSlice(pageable);
 	}
 
 	@Override
 	public Page<R> page(Pageable pageable) {
-		return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSortOr(this.sort))) : readPage(pageable, spec);
+		return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSort())) : readPage(pageable, spec);
 	}
 
 	@Override
 	@SuppressWarnings({ "rawtypes", "unchecked" })
 	public Page<R> page(Pageable pageable, Specification<?> countSpec) {
-		return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSortOr(this.sort)))
+		return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSort()))
 				: readPage(pageable, (Specification) countSpec);
 	}
 
@@ -242,6 +243,26 @@ private Slice<R> readSlice(Pageable pageable) {
 		return new SliceImpl<>(slice, pageable, hasNext);
 	}
 
+	private Slice<R> readSlice(Pageable pageable, @Nullable Specification<S> countSpec) {
+
+		TypedQuery<S> pagedQuery = createSortedAndProjectedQuery(pageable.getSort());
+
+		if (pageable.isPaged()) {
+			pagedQuery.setFirstResult(PageableUtils.getOffsetAsInteger(pageable));
+			pagedQuery.setMaxResults(pageable.getPageSize() + 1);
+		}
+
+		List<S> resultList = pagedQuery.getResultList();
+		boolean hasNext = resultList.size() > pageable.getPageSize();
+		if (hasNext) {
+			resultList = resultList.subList(0, pageable.getPageSize());
+		}
+
+		List<R> slice = convert(resultList);
+
+		return new SliceImpl<>(slice, pageable, hasNext);
+	}
+
 	private Page<R> readPage(Pageable pageable, @Nullable Specification<S> countSpec) {
 
 		Sort sort = pageable.getSortOr(this.sort);
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java
index 10b484d98a..f530e6eade 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java
@@ -22,6 +22,8 @@
 import java.util.function.Function;
 
 import org.springframework.core.convert.support.DefaultConversionService;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.domain.ScrollPosition;
@@ -29,7 +31,6 @@
 import org.springframework.data.jpa.repository.query.AbstractJpaQuery;
 import org.springframework.data.projection.ProjectionFactory;
 import org.springframework.data.repository.query.ReturnedType;
-import org.springframework.lang.Nullable;
 
 /**
  * Supporting class containing some state and convenience methods for building and executing fluent queries.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java
index 9c367343c5..6b2e6361e9 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java
@@ -20,8 +20,6 @@
 import java.util.Arrays;
 import java.util.List;
 
-import org.springframework.lang.Nullable;
-
 import com.querydsl.core.types.Expression;
 import com.querydsl.core.types.ExpressionBase;
 import com.querydsl.core.types.ExpressionUtils;
@@ -31,6 +29,7 @@
 import com.querydsl.core.types.Projections;
 import com.querydsl.core.types.Visitor;
 import com.querydsl.jpa.JPQLSerializer;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Expression based on a {@link Tuple}. It's a simplified variant of {@link com.querydsl.core.types.QTuple} without
@@ -72,8 +71,7 @@ protected JakartaTuple(List<Expression<?>> args) {
 	}
 
 	@Override
-	@Nullable
-	public <R, C> R accept(Visitor<R, C> v, @Nullable C context) {
+	public <R, C> @Nullable R accept(Visitor<R, C> v, @Nullable C context) {
 
 		if (v instanceof JPQLSerializer) {
 			return Projections.tuple(args).accept(v, context);
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java
index 98828424ab..1e378c3308 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java
@@ -21,8 +21,9 @@
 import java.util.Map;
 
 import org.springframework.data.jpa.repository.query.JpaEntityMetadata;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.repository.core.EntityInformation;
-import org.springframework.lang.Nullable;
 
 /**
  * Extension of {@link EntityInformation} to capture additional JPA specific information about entities.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupport.java
index 6d8c0ba8dc..62af516073 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupport.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupport.java
@@ -35,7 +35,7 @@
 public abstract class JpaEntityInformationSupport<T, ID> extends AbstractEntityInformation<T, ID>
 		implements JpaEntityInformation<T, ID> {
 
-	private JpaEntityMetadata<T> metadata;
+	private final JpaEntityMetadata<T> metadata;
 
 	/**
 	 * Creates a new {@link JpaEntityInformationSupport} with the given domain class.
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEvaluationContextExtension.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEvaluationContextExtension.java
index f635a221a4..347b10fde5 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEvaluationContextExtension.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEvaluationContextExtension.java
@@ -15,6 +15,7 @@
  */
 package org.springframework.data.jpa.repository.support;
 
+import org.jspecify.annotations.Nullable;
 import org.springframework.data.jpa.repository.query.EscapeCharacter;
 import org.springframework.data.spel.spi.EvaluationContextExtension;
 
@@ -66,7 +67,7 @@ public static JpaRootObject of(EscapeCharacter character) {
 		 * @return
 		 * @see EscapeCharacter#escape(String)
 		 */
-		public String escape(String source) {
+		public @Nullable String escape(@Nullable String source) {
 			return character.escape(source);
 		}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java
index 4f337709cc..66994749da 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java
@@ -38,11 +38,12 @@
 import java.util.function.Function;
 
 import org.springframework.beans.BeanWrapper;
+
+import org.jspecify.annotations.Nullable;
 import org.springframework.core.annotation.AnnotationUtils;
 import org.springframework.data.jpa.provider.PersistenceProvider;
 import org.springframework.data.jpa.util.JpaMetamodel;
 import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
@@ -143,9 +144,8 @@ public String getEntityName() {
 	}
 
 	@Override
-	@Nullable
 	@SuppressWarnings("unchecked")
-	public ID getId(T entity) {
+	public @Nullable ID getId(T entity) {
 
 		// check if this is a proxy. If so use Proxy mechanics to access the id.
 		PersistenceProvider persistenceProvider = PersistenceProvider.fromMetamodel(metamodel);
@@ -215,7 +215,7 @@ public Collection<String> getIdAttributeNames() {
 	}
 
 	@Override
-	public Object getCompositeIdAttributeValue(Object id, String idAttribute) {
+	public @Nullable Object getCompositeIdAttributeValue(Object id, String idAttribute) {
 
 		Assert.isTrue(hasCompositeId(), "Model must have a composite Id");
 
@@ -312,8 +312,7 @@ public Class<?> getType() {
 			return this.idType;
 		}
 
-		@Nullable
-		private Class<?> tryExtractIdTypeWithFallbackToIdTypeLookup() {
+		private @Nullable Class<?> tryExtractIdTypeWithFallbackToIdTypeLookup() {
 
 			try {
 
@@ -330,8 +329,7 @@ private Class<?> tryExtractIdTypeWithFallbackToIdTypeLookup() {
 			}
 		}
 
-		@Nullable
-		private static Class<?> lookupIdClass(IdentifiableType<?> type) {
+		private static @Nullable Class<?> lookupIdClass(IdentifiableType<?> type) {
 
 			IdClass annotation = type.getJavaType() != null
 					? AnnotationUtils.findAnnotation(type.getJavaType(), IdClass.class)
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaPersistableEntityInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaPersistableEntityInformation.java
index aaaff2050c..5832047303 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaPersistableEntityInformation.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaPersistableEntityInformation.java
@@ -19,7 +19,8 @@
 import jakarta.persistence.metamodel.Metamodel;
 
 import org.springframework.data.domain.Persistable;
-import org.springframework.lang.Nullable;
+
+import org.jspecify.annotations.Nullable;
 
 /**
  * Extension of {@link JpaMetamodelEntityInformation} that consideres methods of {@link Persistable} to lookup the id.
@@ -48,9 +49,8 @@ public boolean isNew(T entity) {
 		return entity.isNew();
 	}
 
-	@Nullable
 	@Override
-	public ID getId(T entity) {
+	public @Nullable ID getId(T entity) {
 		return entity.getId();
 	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactory.java
index e14658773b..bbccb5b979 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactory.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactory.java
@@ -15,8 +15,6 @@
  */
 package org.springframework.data.jpa.repository.support;
 
-import static org.springframework.data.querydsl.QuerydslUtils.*;
-
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.Tuple;
 
@@ -27,41 +25,31 @@
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
+import org.jspecify.annotations.Nullable;
 
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.BeansException;
 import org.springframework.beans.factory.BeanFactory;
-import org.springframework.dao.InvalidDataAccessApiUsageException;
 import org.springframework.data.jpa.projection.CollectionAwareProjectionFactory;
 import org.springframework.data.jpa.provider.PersistenceProvider;
-import org.springframework.data.jpa.provider.QueryExtractor;
 import org.springframework.data.jpa.repository.JpaRepository;
-import org.springframework.data.jpa.repository.query.AbstractJpaQuery;
-import org.springframework.data.jpa.repository.query.BeanFactoryQueryRewriterProvider;
-import org.springframework.data.jpa.repository.query.DefaultJpaQueryMethodFactory;
-import org.springframework.data.jpa.repository.query.EscapeCharacter;
-import org.springframework.data.jpa.repository.query.JpaQueryLookupStrategy;
-import org.springframework.data.jpa.repository.query.JpaQueryMethod;
-import org.springframework.data.jpa.repository.query.JpaQueryMethodFactory;
-import org.springframework.data.jpa.repository.query.Procedure;
-import org.springframework.data.jpa.repository.query.QueryRewriterProvider;
+import org.springframework.data.jpa.repository.query.*;
 import org.springframework.data.jpa.util.JpaMetamodel;
 import org.springframework.data.projection.ProjectionFactory;
 import org.springframework.data.querydsl.EntityPathResolver;
-import org.springframework.data.querydsl.QuerydslPredicateExecutor;
 import org.springframework.data.querydsl.SimpleEntityPathResolver;
 import org.springframework.data.repository.core.RepositoryInformation;
 import org.springframework.data.repository.core.RepositoryMetadata;
 import org.springframework.data.repository.core.support.QueryCreationListener;
 import org.springframework.data.repository.core.support.RepositoryComposition.RepositoryFragments;
 import org.springframework.data.repository.core.support.RepositoryFactorySupport;
+import org.springframework.data.repository.core.support.RepositoryFragment;
 import org.springframework.data.repository.core.support.SurroundingTransactionDetectorMethodInterceptor;
 import org.springframework.data.repository.query.CachingValueExpressionDelegate;
 import org.springframework.data.repository.query.QueryLookupStrategy;
 import org.springframework.data.repository.query.QueryLookupStrategy.Key;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.repository.query.ValueExpressionDelegate;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 import org.springframework.util.ReflectionUtils;
 
@@ -82,12 +70,13 @@
 public class JpaRepositoryFactory extends RepositoryFactorySupport {
 
 	private final EntityManager entityManager;
-	private final QueryExtractor extractor;
 	private final CrudMethodMetadataPostProcessor crudMethodMetadataPostProcessor;
 	private final CrudMethodMetadata crudMethodMetadata;
 
 	private EntityPathResolver entityPathResolver;
 	private EscapeCharacter escapeCharacter = EscapeCharacter.DEFAULT;
+	private JpaRepositoryFragmentsContributor fragmentsContributor = JpaRepositoryFragmentsContributor.DEFAULT;
+	private QueryEnhancerSelector queryEnhancerSelector = QueryEnhancerSelector.DEFAULT_SELECTOR;
 	private JpaQueryMethodFactory queryMethodFactory;
 	private QueryRewriterProvider queryRewriterProvider;
 
@@ -101,7 +90,7 @@ public JpaRepositoryFactory(EntityManager entityManager) {
 		Assert.notNull(entityManager, "EntityManager must not be null");
 
 		this.entityManager = entityManager;
-		this.extractor = PersistenceProvider.fromEntityManager(entityManager);
+		PersistenceProvider extractor = PersistenceProvider.fromEntityManager(entityManager);
 		this.crudMethodMetadataPostProcessor = new CrudMethodMetadataPostProcessor();
 		this.entityPathResolver = SimpleEntityPathResolver.INSTANCE;
 		this.queryMethodFactory = new DefaultJpaQueryMethodFactory(extractor);
@@ -123,7 +112,7 @@ public JpaRepositoryFactory(EntityManager entityManager) {
 	}
 
 	@Override
-	public void setBeanClassLoader(ClassLoader classLoader) {
+	public void setBeanClassLoader(@Nullable ClassLoader classLoader) {
 
 		super.setBeanClassLoader(classLoader);
 		this.crudMethodMetadataPostProcessor.setBeanClassLoader(classLoader);
@@ -167,6 +156,17 @@ public void setEscapeCharacter(EscapeCharacter escapeCharacter) {
 		this.escapeCharacter = escapeCharacter;
 	}
 
+	/**
+	 * Configures the {@link JpaRepositoryFragmentsContributor} to be used. Defaults to
+	 * {@link JpaRepositoryFragmentsContributor#DEFAULT}.
+	 *
+	 * @param fragmentsContributor
+	 * @since 4.0
+	 */
+	public void setFragmentsContributor(JpaRepositoryFragmentsContributor fragmentsContributor) {
+		this.fragmentsContributor = fragmentsContributor;
+	}
+
 	/**
 	 * Configures the {@link JpaQueryMethodFactory} to be used. Defaults to {@link DefaultJpaQueryMethodFactory}.
 	 *
@@ -179,6 +179,19 @@ public void setQueryMethodFactory(JpaQueryMethodFactory queryMethodFactory) {
 		this.queryMethodFactory = queryMethodFactory;
 	}
 
+	/**
+	 * Configures the {@link QueryEnhancerSelector} to be used. Defaults to
+	 * {@link QueryEnhancerSelector#DEFAULT_SELECTOR}.
+	 *
+	 * @param queryEnhancerSelector must not be {@literal null}.
+	 */
+	public void setQueryEnhancerSelector(QueryEnhancerSelector queryEnhancerSelector) {
+
+		Assert.notNull(queryEnhancerSelector, "QueryEnhancerSelector must not be null");
+
+		this.queryEnhancerSelector = queryEnhancerSelector;
+	}
+
 	/**
 	 * Configures the {@link QueryRewriterProvider} to be used. Defaults to instantiate query rewriters through
 	 * {@link BeanUtils#instantiateClass(Class)}.
@@ -226,11 +239,16 @@ protected Class<?> getRepositoryBaseClass(RepositoryMetadata metadata) {
 	}
 
 	@Override
-	protected ProjectionFactory getProjectionFactory(ClassLoader classLoader, BeanFactory beanFactory) {
+	protected ProjectionFactory getProjectionFactory(@Nullable ClassLoader classLoader,
+			@Nullable BeanFactory beanFactory) {
 
 		CollectionAwareProjectionFactory factory = new CollectionAwareProjectionFactory();
-		factory.setBeanClassLoader(classLoader);
-		factory.setBeanFactory(beanFactory);
+		if (classLoader != null) {
+			factory.setBeanClassLoader(classLoader);
+		}
+		if (beanFactory != null) {
+			factory.setBeanFactory(beanFactory);
+		}
 
 		return factory;
 	}
@@ -238,59 +256,50 @@ protected ProjectionFactory getProjectionFactory(ClassLoader classLoader, BeanFa
 	@Override
 	protected Optional<QueryLookupStrategy> getQueryLookupStrategy(@Nullable Key key,
 			ValueExpressionDelegate valueExpressionDelegate) {
+
+		JpaQueryConfiguration queryConfiguration = new JpaQueryConfiguration(queryRewriterProvider, queryEnhancerSelector,
+				new CachingValueExpressionDelegate(valueExpressionDelegate), escapeCharacter);
+
 		return Optional.of(JpaQueryLookupStrategy.create(entityManager, queryMethodFactory, key,
-				new CachingValueExpressionDelegate(valueExpressionDelegate),
-				queryRewriterProvider, escapeCharacter));
+				queryConfiguration));
 	}
 
-
 	@Override
 	@SuppressWarnings("unchecked")
 	public <T, ID> JpaEntityInformation<T, ID> getEntityInformation(Class<T> domainClass) {
-
 		return (JpaEntityInformation<T, ID>) JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);
 	}
 
 	@Override
 	protected RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) {
-
 		return getRepositoryFragments(metadata, entityManager, entityPathResolver, this.crudMethodMetadata);
 	}
 
 	/**
-	 * Creates {@link RepositoryFragments} based on {@link RepositoryMetadata} to add JPA-specific extensions. Typically
+	 * Creates {@link RepositoryFragments} based on {@link RepositoryMetadata} to add JPA-specific extensions. Typically,
 	 * adds a {@link QuerydslJpaPredicateExecutor} if the repository interface uses Querydsl.
 	 * <p>
-	 * Can be overridden by subclasses to customize {@link RepositoryFragments}.
+	 * Built-in fragment contribution can be customized by configuring {@link JpaRepositoryFragmentsContributor}.
 	 *
 	 * @param metadata repository metadata.
 	 * @param entityManager the entity manager.
 	 * @param resolver resolver to translate a plain domain class into a {@link EntityPath}.
 	 * @param crudMethodMetadata metadata about the invoked CRUD methods.
-	 * @return
+	 * @return {@link RepositoryFragments} to be added to the repository.
 	 * @since 2.5.1
 	 */
 	protected RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata, EntityManager entityManager,
 			EntityPathResolver resolver, CrudMethodMetadata crudMethodMetadata) {
 
-		boolean isQueryDslRepository = QUERY_DSL_PRESENT
-				&& QuerydslPredicateExecutor.class.isAssignableFrom(metadata.getRepositoryInterface());
-
-		if (isQueryDslRepository) {
-
-			if (metadata.isReactiveRepository()) {
-				throw new InvalidDataAccessApiUsageException(
-						"Cannot combine Querydsl and reactive repository support in a single interface");
-			}
-
-			QuerydslJpaPredicateExecutor<?> querydslJpaPredicateExecutor = new QuerydslJpaPredicateExecutor<>(
-					getEntityInformation(metadata.getDomainType()), entityManager, resolver, crudMethodMetadata);
-			invokeAwareMethods(querydslJpaPredicateExecutor);
+		RepositoryFragments fragments = this.fragmentsContributor.contribute(metadata,
+				getEntityInformation(metadata.getDomainType()), entityManager, resolver);
 
-			return RepositoryFragments.just(querydslJpaPredicateExecutor);
+		for (RepositoryFragment<?> fragment : fragments) {
+			fragment.getImplementation().filter(JpaRepositoryConfigurationAware.class::isInstance)
+					.ifPresent(it -> invokeAwareMethods((JpaRepositoryConfigurationAware) it));
 		}
 
-		return RepositoryFragments.empty();
+		return fragments;
 	}
 
 	private void invokeAwareMethods(JpaRepositoryConfigurationAware repository) {
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java
index 86f2f14d6c..30461fcabb 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java
@@ -18,17 +18,24 @@
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.PersistenceContext;
 
+import java.util.function.Function;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.beans.BeanUtils;
+import org.springframework.beans.factory.BeanFactory;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
 import org.springframework.data.jpa.repository.query.EscapeCharacter;
 import org.springframework.data.jpa.repository.query.JpaQueryMethodFactory;
+import org.springframework.data.jpa.repository.query.QueryEnhancerSelector;
 import org.springframework.data.mapping.context.MappingContext;
 import org.springframework.data.querydsl.EntityPathResolver;
 import org.springframework.data.querydsl.SimpleEntityPathResolver;
 import org.springframework.data.repository.Repository;
 import org.springframework.data.repository.core.support.RepositoryFactorySupport;
 import org.springframework.data.repository.core.support.TransactionalRepositoryFactoryBeanSupport;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 /**
@@ -45,10 +52,13 @@
 public class JpaRepositoryFactoryBean<T extends Repository<S, ID>, S, ID>
 		extends TransactionalRepositoryFactoryBeanSupport<T, S, ID> {
 
+	private @Nullable BeanFactory beanFactory;
 	private @Nullable EntityManager entityManager;
-	private EntityPathResolver entityPathResolver;
+	private EntityPathResolver entityPathResolver = SimpleEntityPathResolver.INSTANCE;
+	private JpaRepositoryFragmentsContributor repositoryFragmentsContributor = JpaRepositoryFragmentsContributor.DEFAULT;
 	private EscapeCharacter escapeCharacter = EscapeCharacter.DEFAULT;
-	private JpaQueryMethodFactory queryMethodFactory;
+	private @Nullable JpaQueryMethodFactory queryMethodFactory;
+	private @Nullable Function<@Nullable BeanFactory, QueryEnhancerSelector> queryEnhancerSelectorSource;
 
 	/**
 	 * Creates a new {@link JpaRepositoryFactoryBean} for the given repository interface.
@@ -74,6 +84,12 @@ public void setMappingContext(MappingContext<?, ?> mappingContext) {
 		super.setMappingContext(mappingContext);
 	}
 
+	@Override
+	public void setBeanFactory(BeanFactory beanFactory) {
+		this.beanFactory = beanFactory;
+		super.setBeanFactory(beanFactory);
+	}
+
 	/**
 	 * Configures the {@link EntityPathResolver} to be used. Will expect a canonical bean to be present but fallback to
 	 * {@link SimpleEntityPathResolver#INSTANCE} in case none is available.
@@ -85,16 +101,75 @@ public void setEntityPathResolver(ObjectProvider<EntityPathResolver> resolver) {
 		this.entityPathResolver = resolver.getIfAvailable(() -> SimpleEntityPathResolver.INSTANCE);
 	}
 
+	@Override
+	public JpaRepositoryFragmentsContributor getRepositoryFragmentsContributor() {
+		return repositoryFragmentsContributor;
+	}
+
+	/**
+	 * Configures the {@link JpaRepositoryFragmentsContributor} to contribute built-in fragment functionality to the
+	 * repository.
+	 *
+	 * @param repositoryFragmentsContributor must not be {@literal null}.
+	 * @since 4.0
+	 */
+	public void setRepositoryFragmentsContributor(JpaRepositoryFragmentsContributor repositoryFragmentsContributor) {
+		this.repositoryFragmentsContributor = repositoryFragmentsContributor;
+	}
+
+	public void setEscapeCharacter(char escapeCharacter) {
+		this.escapeCharacter = EscapeCharacter.of(escapeCharacter);
+	}
+
+	/**
+	 * Configures the {@link QueryEnhancerSelector} to be used. Defaults to
+	 * {@link QueryEnhancerSelector#DEFAULT_SELECTOR}.
+	 *
+	 * @param queryEnhancerSelectorSource must not be {@literal null}.
+	 */
+	public void setQueryEnhancerSelectorSource(QueryEnhancerSelector queryEnhancerSelectorSource) {
+		this.queryEnhancerSelectorSource = bf -> queryEnhancerSelectorSource;
+	}
+
+	/**
+	 * Configures the {@link QueryEnhancerSelector} to be used.
+	 *
+	 * @param queryEnhancerSelectorType must not be {@literal null}.
+	 */
+	public void setQueryEnhancerSelector(Class<? extends QueryEnhancerSelector> queryEnhancerSelectorType) {
+
+		this.queryEnhancerSelectorSource = bf -> {
+
+			if (bf != null) {
+
+				ObjectProvider<? extends QueryEnhancerSelector> beanProvider = bf.getBeanProvider(queryEnhancerSelectorType);
+				QueryEnhancerSelector selector = beanProvider.getIfAvailable();
+
+				if (selector != null) {
+					return selector;
+				}
+
+				if (bf instanceof AutowireCapableBeanFactory acbf) {
+					return acbf.createBean(queryEnhancerSelectorType);
+				}
+			}
+
+			return BeanUtils.instantiateClass(queryEnhancerSelectorType);
+		};
+	}
+
 	/**
 	 * Configures the {@link JpaQueryMethodFactory} to be used. Will expect a canonical bean to be present but will
 	 * fallback to {@link org.springframework.data.jpa.repository.query.DefaultJpaQueryMethodFactory} in case none is
 	 * available.
 	 *
-	 * @param factory may be {@literal null}.
+	 * @param resolver may be {@literal null}.
 	 */
 	@Autowired
-	public void setQueryMethodFactory(@Nullable JpaQueryMethodFactory factory) {
+	public void setQueryMethodFactory(ObjectProvider<JpaQueryMethodFactory> resolver) { // TODO: nullable insteand of
+																																											// ObjectProvider
 
+		JpaQueryMethodFactory factory = resolver.getIfAvailable();
 		if (factory != null) {
 			this.queryMethodFactory = factory;
 		}
@@ -113,15 +188,20 @@ protected RepositoryFactorySupport doCreateRepositoryFactory() {
 	 */
 	protected RepositoryFactorySupport createRepositoryFactory(EntityManager entityManager) {
 
-		JpaRepositoryFactory jpaRepositoryFactory = new JpaRepositoryFactory(entityManager);
-		jpaRepositoryFactory.setEntityPathResolver(entityPathResolver);
-		jpaRepositoryFactory.setEscapeCharacter(escapeCharacter);
+		JpaRepositoryFactory factory = new JpaRepositoryFactory(entityManager);
+		factory.setEntityPathResolver(entityPathResolver);
+		factory.setEscapeCharacter(escapeCharacter);
+		factory.setFragmentsContributor(getRepositoryFragmentsContributor());
 
 		if (queryMethodFactory != null) {
-			jpaRepositoryFactory.setQueryMethodFactory(queryMethodFactory);
+			factory.setQueryMethodFactory(queryMethodFactory);
+		}
+
+		if (queryEnhancerSelectorSource != null) {
+			factory.setQueryEnhancerSelector(queryEnhancerSelectorSource.apply(beanFactory));
 		}
 
-		return jpaRepositoryFactory;
+		return factory;
 	}
 
 	@Override
@@ -132,8 +212,4 @@ public void afterPropertiesSet() {
 		super.afterPropertiesSet();
 	}
 
-	public void setEscapeCharacter(char escapeCharacter) {
-
-		this.escapeCharacter = EscapeCharacter.of(escapeCharacter);
-	}
 }
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFragmentsContributor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFragmentsContributor.java
new file mode 100644
index 0000000000..03d072b435
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFragmentsContributor.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.support;
+
+import jakarta.persistence.EntityManager;
+
+import org.springframework.data.querydsl.EntityPathResolver;
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryComposition;
+import org.springframework.data.repository.core.support.RepositoryFragmentsContributor;
+import org.springframework.util.Assert;
+
+import com.querydsl.core.types.EntityPath;
+
+/**
+ * JPA-specific {@link RepositoryFragmentsContributor} contributing fragments based on the repository.
+ * <p>
+ * Implementations must define a no-args constructor.
+ * <p>
+ * Contributed fragments may implement the {@link JpaRepositoryConfigurationAware} interface to access configuration
+ * settings.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ */
+public interface JpaRepositoryFragmentsContributor extends RepositoryFragmentsContributor {
+
+	JpaRepositoryFragmentsContributor DEFAULT = QuerydslContributor.INSTANCE;
+
+	/**
+	 * Returns a composed {@code JpaRepositoryFragmentsContributor} that first applies this contributor to its inputs, and
+	 * then applies the {@code after} contributor concatenating effectively both results. If evaluation of either
+	 * contributors throws an exception, it is relayed to the caller of the composed contributor.
+	 *
+	 * @param after the contributor to apply after this contributor is applied.
+	 * @return a composed contributor that first applies this contributor and then applies the {@code after} contributor.
+	 */
+	default JpaRepositoryFragmentsContributor andThen(JpaRepositoryFragmentsContributor after) {
+
+		Assert.notNull(after, "JpaRepositoryFragmentsContributor must not be null");
+
+		return new JpaRepositoryFragmentsContributor() {
+
+			@Override
+			public RepositoryComposition.RepositoryFragments contribute(RepositoryMetadata metadata,
+					JpaEntityInformation<?, ?> entityInformation, EntityManager entityManager, EntityPathResolver resolver) {
+				return JpaRepositoryFragmentsContributor.this.contribute(metadata, entityInformation, entityManager, resolver)
+						.append(after.contribute(metadata, entityInformation, entityManager, resolver));
+			}
+
+			@Override
+			public RepositoryComposition.RepositoryFragments describe(RepositoryMetadata metadata) {
+				return JpaRepositoryFragmentsContributor.this.describe(metadata).append(after.describe(metadata));
+			}
+		};
+	}
+
+	/**
+	 * Creates {@link RepositoryComposition.RepositoryFragments} based on {@link RepositoryMetadata} to add JPA-specific
+	 * extensions. Typically, adds a {@link QuerydslJpaPredicateExecutor} if the repository interface uses Querydsl.
+	 *
+	 * @param metadata repository metadata.
+	 * @param entityInformation must not be {@literal null}.
+	 * @param entityManager the entity manager.
+	 * @param resolver resolver to translate a plain domain class into a {@link EntityPath}.
+	 * @return {@link RepositoryComposition.RepositoryFragments} to be added to the repository.
+	 */
+	RepositoryComposition.RepositoryFragments contribute(RepositoryMetadata metadata,
+			JpaEntityInformation<?, ?> entityInformation, EntityManager entityManager, EntityPathResolver resolver);
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpqlQueryTemplates.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpqlQueryTemplates.java
new file mode 100644
index 0000000000..52590daa8c
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpqlQueryTemplates.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.springframework.data.jpa.repository.support;
+
+import java.util.function.Function;
+
+/**
+ * @author Mark Paluch
+ */
+public class JpqlQueryTemplates {
+
+	public static final JpqlQueryTemplates UPPER = new JpqlQueryTemplates("UPPER", String::toUpperCase);
+
+	public static final JpqlQueryTemplates LOWER = new JpqlQueryTemplates("LOWER", String::toLowerCase);
+
+	private final String ignoreCaseOperator;
+
+	private final Function<String, String> ignoreCaseFunction;
+
+	JpqlQueryTemplates(String ignoreCaseOperator, Function<String, String> ignoreCaseFunction) {
+		this.ignoreCaseOperator = ignoreCaseOperator;
+		this.ignoreCaseFunction = ignoreCaseFunction;
+	}
+
+	public static JpqlQueryTemplates of(String ignoreCaseOperator, Function<String, String> ignoreCaseFunction) {
+		return new JpqlQueryTemplates(ignoreCaseOperator, ignoreCaseFunction);
+	}
+
+	public String ignoreCase(String value) {
+		return ignoreCaseFunction.apply(value);
+	}
+
+	public String getIgnoreCaseOperator() {
+		return ignoreCaseOperator;
+	}
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/Querydsl.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/Querydsl.java
index 62155b8f0b..da00e05368 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/Querydsl.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/Querydsl.java
@@ -25,7 +25,6 @@
 import org.springframework.data.jpa.provider.PersistenceProvider;
 import org.springframework.data.mapping.PropertyPath;
 import org.springframework.data.querydsl.QSort;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 import com.querydsl.core.types.EntityPath;
@@ -41,6 +40,7 @@
 import com.querydsl.jpa.JPQLTemplates;
 import com.querydsl.jpa.impl.AbstractJPAQuery;
 import com.querydsl.jpa.impl.JPAQuery;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Helper instance to ease access to Querydsl JPA query API.
@@ -87,10 +87,9 @@ public <T> AbstractJPAQuery<T, JPAQuery<T>> createQuery() {
 	 * Obtains the {@link JPQLTemplates} for the configured {@link EntityManager}. Can return {@literal null} to use the
 	 * default templates.
 	 *
-	 * @return the {@link JPQLTemplates} for the configured {@link EntityManager} or {@literal null} to use the default.
+	 * @return the {@link JPQLTemplates} for the configured {@link EntityManager}, {@link JPQLTemplates#DEFAULT} by default.
 	 * @since 3.5
 	 */
-	@Nullable
 	public JPQLTemplates getTemplates() {
 
 		return switch (provider) {
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslContributor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslContributor.java
new file mode 100644
index 0000000000..280ac954c3
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslContributor.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.support;
+
+import static org.springframework.data.querydsl.QuerydslUtils.*;
+
+import jakarta.persistence.EntityManager;
+
+import org.springframework.dao.InvalidDataAccessApiUsageException;
+import org.springframework.data.querydsl.EntityPathResolver;
+import org.springframework.data.querydsl.QuerydslPredicateExecutor;
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryComposition;
+import org.springframework.data.repository.core.support.RepositoryFragment;
+import org.springframework.data.repository.core.support.RepositoryFragmentsContributor;
+
+/**
+ * JPA-specific {@link RepositoryFragmentsContributor} contributing Querydsl fragments if a repository implements
+ * {@link QuerydslPredicateExecutor}.
+ *
+ * @author Mark Paluch
+ * @since 4.0
+ * @see QuerydslJpaPredicateExecutor
+ */
+enum QuerydslContributor implements JpaRepositoryFragmentsContributor {
+
+	INSTANCE;
+
+	@Override
+	public RepositoryComposition.RepositoryFragments contribute(RepositoryMetadata metadata,
+			JpaEntityInformation<?, ?> entityInformation, EntityManager entityManager, EntityPathResolver resolver) {
+
+		if (isQuerydslRepository(metadata)) {
+
+			if (metadata.isReactiveRepository()) {
+				throw new InvalidDataAccessApiUsageException(
+						"Cannot combine Querydsl and reactive repository support in a single interface");
+			}
+
+			QuerydslJpaPredicateExecutor<?> executor = new QuerydslJpaPredicateExecutor<>(entityInformation, entityManager,
+					resolver, null);
+
+			return RepositoryComposition.RepositoryFragments
+					.of(RepositoryFragment.implemented(QuerydslJpaPredicateExecutor.class, executor));
+		}
+
+		return RepositoryComposition.RepositoryFragments.empty();
+	}
+
+	@Override
+	public RepositoryComposition.RepositoryFragments describe(RepositoryMetadata metadata) {
+
+		if (isQuerydslRepository(metadata)) {
+			return RepositoryComposition.RepositoryFragments
+					.of(RepositoryFragment.structural(QuerydslJpaPredicateExecutor.class, QuerydslJpaPredicateExecutor.class));
+		}
+
+		return RepositoryComposition.RepositoryFragments.empty();
+	}
+
+	private static boolean isQuerydslRepository(RepositoryMetadata metadata) {
+		return QUERY_DSL_PRESENT && QuerydslPredicateExecutor.class.isAssignableFrom(metadata.getRepositoryInterface());
+	}
+
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java
index b37a6e0209..d04ca6d8b5 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java
@@ -23,6 +23,8 @@
 import java.util.function.BiFunction;
 import java.util.function.Function;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.dao.IncorrectResultSizeDataAccessException;
 import org.springframework.dao.InvalidDataAccessApiUsageException;
 import org.springframework.data.domain.KeysetScrollPosition;
@@ -44,7 +46,6 @@
 import org.springframework.data.repository.query.FluentQuery;
 import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery;
 import org.springframework.data.support.PageableExecutionUtils;
-import org.springframework.lang.Nullable;
 import org.springframework.util.Assert;
 
 import com.querydsl.core.NonUniqueResultException;
@@ -262,6 +263,33 @@ public long count(Predicate predicate) {
 		return createQuery(predicate).fetchCount();
 	}
 
+	/**
+	 * Delete entities by the given {@link Predicate} by loading and removing these.
+	 * <p>
+	 * This method is useful for a small amount of entities. For large amounts of entities, consider using batch deletes
+	 * by declaring a delete query yourself.
+	 *
+	 * @param predicate the {@link Predicate} to delete entities by, must not be {@literal null}.
+	 * @return number of deleted entities.
+	 * @since 4.0
+	 */
+	public long delete(Predicate predicate) {
+
+		Assert.notNull(predicate, PREDICATE_MUST_NOT_BE_NULL);
+
+		List<T> results = (List<T>) createQuery(predicate).fetch();
+
+		int deleted = 0;
+
+		for (T entity : results) {
+			if (SimpleJpaRepository.doDelete(entityManager, entityInformation, entity)) {
+				deleted++;
+			}
+		}
+
+		return deleted;
+	}
+
 	@Override
 	public boolean exists(Predicate predicate) {
 		return createQuery(predicate).select(Expressions.ONE).fetchFirst() != null;
@@ -297,8 +325,7 @@ protected JPQLQuery<?> createCountQuery(@Nullable Predicate... predicate) {
 		return doCreateQuery(getQueryHintsForCount(), predicate);
 	}
 
-	@Nullable
-	private CrudMethodMetadata getRepositoryMethodMetadata() {
+	private @Nullable CrudMethodMetadata getRepositoryMethodMetadata() {
 		return metadata;
 	}
 
@@ -375,13 +402,18 @@ public Expression<?> createExpression(String property) {
 		}
 
 		@Override
-		public BooleanExpression compare(Order order, Expression<?> propertyExpression, Object value) {
+		public BooleanExpression compare(Order order, Expression<?> propertyExpression, @Nullable Object value) {
+
+			if (value == null) {
+				return Expressions.booleanOperation(order.isAscending() ? Ops.IS_NULL : Ops.IS_NOT_NULL, propertyExpression);
+			}
+
 			return Expressions.booleanOperation(order.isAscending() ? Ops.GT : Ops.LT, propertyExpression,
 					ConstantImpl.create(value));
 		}
 
 		@Override
-		public BooleanExpression compare(Expression<?> propertyExpression, @Nullable Object value) {
+		public BooleanExpression compare(String property, Expression<?> propertyExpression, @Nullable Object value) {
 			return Expressions.booleanOperation(Ops.EQ, propertyExpression,
 					value == null ? NullExpression.DEFAULT : ConstantImpl.create(value));
 		}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepository.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepository.java
deleted file mode 100644
index 129d56f6e9..0000000000
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepository.java
+++ /dev/null
@@ -1,228 +0,0 @@
-/*
- * Copyright 2008-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.springframework.data.jpa.repository.support;
-
-import java.io.Serializable;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.Function;
-
-import jakarta.persistence.EntityManager;
-import jakarta.persistence.LockModeType;
-
-import org.springframework.dao.IncorrectResultSizeDataAccessException;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.Pageable;
-import org.springframework.data.domain.Sort;
-import org.springframework.data.querydsl.EntityPathResolver;
-import org.springframework.data.querydsl.QSort;
-import org.springframework.data.querydsl.QuerydslPredicateExecutor;
-import org.springframework.data.querydsl.SimpleEntityPathResolver;
-import org.springframework.data.repository.query.FluentQuery;
-import org.springframework.data.support.PageableExecutionUtils;
-import org.springframework.lang.Nullable;
-import org.springframework.util.Assert;
-
-import com.querydsl.core.NonUniqueResultException;
-import com.querydsl.core.types.EntityPath;
-import com.querydsl.core.types.OrderSpecifier;
-import com.querydsl.core.types.Predicate;
-import com.querydsl.core.types.dsl.PathBuilder;
-import com.querydsl.jpa.JPQLQuery;
-import com.querydsl.jpa.impl.AbstractJPAQuery;
-
-/**
- * QueryDsl specific extension of {@link SimpleJpaRepository} which adds implementation for
- * {@link QuerydslPredicateExecutor}.
- *
- * @author Oliver Gierke
- * @author Thomas Darimont
- * @author Mark Paluch
- * @author Jocelyn Ntakpe
- * @author Christoph Strobl
- * @author Jens Schauder
- * @author Greg Turnquist
- * @author Yanming Zhou
- * @deprecated Instead of this class use {@link QuerydslJpaPredicateExecutor}
- */
-@Deprecated
-public class QuerydslJpaRepository<T, ID extends Serializable> extends SimpleJpaRepository<T, ID>
-		implements QuerydslPredicateExecutor<T> {
-
-	private final EntityPath<T> path;
-	private final PathBuilder<T> builder;
-	private final Querydsl querydsl;
-	private final EntityManager entityManager;
-
-	/**
-	 * Creates a new {@link QuerydslJpaRepository} from the given domain class and {@link EntityManager}. This will use
-	 * the {@link SimpleEntityPathResolver} to translate the given domain class into an {@link EntityPath}.
-	 *
-	 * @param entityInformation must not be {@literal null}.
-	 * @param entityManager must not be {@literal null}.
-	 */
-	public QuerydslJpaRepository(JpaEntityInformation<T, ID> entityInformation, EntityManager entityManager) {
-		this(entityInformation, entityManager, SimpleEntityPathResolver.INSTANCE);
-	}
-
-	/**
-	 * Creates a new {@link QuerydslJpaRepository} from the given domain class and {@link EntityManager} and uses the
-	 * given {@link EntityPathResolver} to translate the domain class into an {@link EntityPath}.
-	 *
-	 * @param entityInformation must not be {@literal null}.
-	 * @param entityManager must not be {@literal null}.
-	 * @param resolver must not be {@literal null}.
-	 */
-	public QuerydslJpaRepository(JpaEntityInformation<T, ID> entityInformation, EntityManager entityManager,
-			EntityPathResolver resolver) {
-
-		super(entityInformation, entityManager);
-
-		this.path = resolver.createPath(entityInformation.getJavaType());
-		this.builder = new PathBuilder<>(path.getType(), path.getMetadata());
-		this.querydsl = new Querydsl(entityManager, builder);
-		this.entityManager = entityManager;
-	}
-
-	@Override
-	public Optional<T> findOne(Predicate predicate) {
-
-		try {
-			return Optional.ofNullable(createQuery(predicate).select(path).limit(2).fetchOne());
-		} catch (NonUniqueResultException ex) {
-			throw new IncorrectResultSizeDataAccessException(ex.getMessage(), 1, ex);
-		}
-	}
-
-	@Override
-	public List<T> findAll(Predicate predicate) {
-		return createQuery(predicate).select(path).fetch();
-	}
-
-	@Override
-	public List<T> findAll(Predicate predicate, OrderSpecifier<?>... orders) {
-		return executeSorted(createQuery(predicate).select(path), orders);
-	}
-
-	@Override
-	public List<T> findAll(Predicate predicate, Sort sort) {
-
-		Assert.notNull(sort, "Sort must not be null");
-
-		return executeSorted(createQuery(predicate).select(path), sort);
-	}
-
-	@Override
-	public List<T> findAll(OrderSpecifier<?>... orders) {
-
-		Assert.notNull(orders, "Order specifiers must not be null");
-
-		return executeSorted(createQuery(new Predicate[0]).select(path), orders);
-	}
-
-	@Override
-	public Page<T> findAll(Predicate predicate, Pageable pageable) {
-
-		Assert.notNull(pageable, "Pageable must not be null");
-
-		final JPQLQuery<?> countQuery = createCountQuery(predicate);
-		JPQLQuery<T> query = querydsl.applyPagination(pageable, createQuery(predicate).select(path));
-
-		return PageableExecutionUtils.getPage(query.fetch(), pageable, countQuery::fetchCount);
-	}
-
-	@Override
-	public <S extends T, R> R findBy(Predicate predicate,
-			Function<FluentQuery.FetchableFluentQuery<S>, R> queryFunction) {
-		throw new UnsupportedOperationException(
-				"Fluent Query API support for Querydsl is only found in QuerydslJpaPredicateExecutor.");
-	}
-
-	@Override
-	public long count(Predicate predicate) {
-		return createQuery(predicate).fetchCount();
-	}
-
-	@Override
-	public boolean exists(Predicate predicate) {
-		return createQuery(predicate).fetchCount() > 0;
-	}
-
-	/**
-	 * Creates a new {@link JPQLQuery} for the given {@link Predicate}.
-	 *
-	 * @param predicate
-	 * @return the Querydsl {@link JPQLQuery}.
-	 */
-	protected JPQLQuery<?> createQuery(Predicate... predicate) {
-
-		AbstractJPAQuery<?, ?> query = doCreateQuery(getQueryHints().withFetchGraphs(entityManager), predicate);
-
-		CrudMethodMetadata metadata = getRepositoryMethodMetadata();
-
-		if (metadata == null) {
-			return query;
-		}
-
-		LockModeType type = metadata.getLockModeType();
-		return type == null ? query : query.setLockMode(type);
-	}
-
-	/**
-	 * Creates a new {@link JPQLQuery} count query for the given {@link Predicate}.
-	 *
-	 * @param predicate, can be {@literal null}.
-	 * @return the Querydsl count {@link JPQLQuery}.
-	 */
-	protected JPQLQuery<?> createCountQuery(@Nullable Predicate... predicate) {
-		return doCreateQuery(getQueryHints(), predicate);
-	}
-
-	private AbstractJPAQuery<?, ?> doCreateQuery(QueryHints hints, @Nullable Predicate... predicate) {
-
-		AbstractJPAQuery<?, ?> query = querydsl.createQuery(path);
-
-		if (predicate != null) {
-			query = query.where(predicate);
-		}
-
-		hints.forEach(query::setHint);
-
-		return query;
-	}
-
-	/**
-	 * Executes the given {@link JPQLQuery} after applying the given {@link OrderSpecifier}s.
-	 *
-	 * @param query must not be {@literal null}.
-	 * @param orders must not be {@literal null}.
-	 * @return
-	 */
-	private List<T> executeSorted(JPQLQuery<T> query, OrderSpecifier<?>... orders) {
-		return executeSorted(query, new QSort(orders));
-	}
-
-	/**
-	 * Executes the given {@link JPQLQuery} after applying the given {@link Sort}.
-	 *
-	 * @param query must not be {@literal null}.
-	 * @param sort must not be {@literal null}.
-	 * @return
-	 */
-	private List<T> executeSorted(JPQLQuery<T> query, Sort sort) {
-		return querydsl.applySorting(sort, query).fetch();
-	}
-}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupport.java
index 09c43e198b..562b2ad25d 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupport.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupport.java
@@ -16,10 +16,11 @@
 package org.springframework.data.jpa.repository.support;
 
 import jakarta.annotation.PostConstruct;
+import org.jspecify.annotations.Nullable;
+
 import jakarta.persistence.EntityManager;
 
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.lang.Nullable;
 import org.springframework.stereotype.Repository;
 import org.springframework.util.Assert;
 
@@ -84,8 +85,7 @@ public void validate() {
 	 *
 	 * @return the entityManager
 	 */
-	@Nullable
-	protected EntityManager getEntityManager() {
+	protected @Nullable EntityManager getEntityManager() {
 		return entityManager;
 	}
 
@@ -145,8 +145,7 @@ protected <T> PathBuilder<T> getBuilder() {
 	 *
 	 * @return
 	 */
-	@Nullable
-	protected Querydsl getQuerydsl() {
+	protected @Nullable Querydsl getQuerydsl() {
 		return this.querydsl;
 	}
 
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java
index 9f649069c2..89395e4fcf 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SimpleJpaRepository.java
@@ -19,20 +19,17 @@
 
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.LockModeType;
-import jakarta.persistence.NoResultException;
-import jakarta.persistence.Parameter;
 import jakarta.persistence.Query;
 import jakarta.persistence.TypedQuery;
 import jakarta.persistence.criteria.CriteriaBuilder;
 import jakarta.persistence.criteria.CriteriaDelete;
 import jakarta.persistence.criteria.CriteriaQuery;
-import jakarta.persistence.criteria.ParameterExpression;
+import jakarta.persistence.criteria.CriteriaUpdate;
 import jakarta.persistence.criteria.Path;
 import jakarta.persistence.criteria.Predicate;
 import jakarta.persistence.criteria.Root;
 import jakarta.persistence.criteria.Selection;
 
-import java.io.Serial;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -43,6 +40,8 @@
 import java.util.function.BiConsumer;
 import java.util.function.Function;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.dao.InvalidDataAccessApiUsageException;
 import org.springframework.data.domain.Example;
 import org.springframework.data.domain.KeysetScrollPosition;
@@ -53,7 +52,9 @@
 import org.springframework.data.domain.ScrollPosition;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jpa.convert.QueryByExamplePredicateBuilder;
+import org.springframework.data.jpa.domain.DeleteSpecification;
 import org.springframework.data.jpa.domain.Specification;
+import org.springframework.data.jpa.domain.UpdateSpecification;
 import org.springframework.data.jpa.provider.PersistenceProvider;
 import org.springframework.data.jpa.repository.EntityGraph;
 import org.springframework.data.jpa.repository.query.EscapeCharacter;
@@ -73,7 +74,7 @@
 import org.springframework.data.support.PageableExecutionUtils;
 import org.springframework.data.util.ProxyUtils;
 import org.springframework.data.util.Streamable;
-import org.springframework.lang.Nullable;
+import org.springframework.lang.Contract;
 import org.springframework.stereotype.Repository;
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.util.Assert;
@@ -170,8 +171,7 @@ public void setProjectionFactory(ProjectionFactory projectionFactory) {
 		this.projectionFactory = projectionFactory;
 	}
 
-	@Nullable
-	protected CrudMethodMetadata getRepositoryMethodMetadata() {
+	protected @Nullable CrudMethodMetadata getRepositoryMethodMetadata() {
 		return metadata;
 	}
 
@@ -205,13 +205,18 @@ public void delete(T entity) {
 
 		Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL);
 
+		doDelete(entityManager, entityInformation, entity);
+	}
+
+	static <T> boolean doDelete(EntityManager entityManager, JpaEntityInformation<T, ?> entityInformation, T entity) {
+
 		if (entityInformation.isNew(entity)) {
-			return;
+			return false;
 		}
 
 		if (entityManager.contains(entity)) {
 			entityManager.remove(entity);
-			return;
+			return true;
 		}
 
 		Class<?> type = ProxyUtils.getUserClass(entity);
@@ -220,7 +225,11 @@ public void delete(T entity) {
 		T existing = (T) entityManager.find(type, entityInformation.getId(entity));
 		if (existing != null) {
 			entityManager.remove(entityManager.merge(entity));
+
+			return true;
 		}
+
+		return false;
 	}
 
 	@Override
@@ -253,7 +262,7 @@ public void deleteAllByIdInBatch(Iterable<ID> ids) {
 		} else {
 
 			String queryString = String.format(DELETE_ALL_QUERY_BY_ID_STRING, entityInformation.getEntityName(),
-					entityInformation.getIdAttribute().getName());
+					entityInformation.getRequiredIdAttribute().getName());
 
 			Query query = entityManager.createQuery(queryString);
 
@@ -399,7 +408,7 @@ public boolean existsById(ID id) {
 
 	@Override
 	public List<T> findAll() {
-		return getQuery(null, Sort.unsorted()).getResultList();
+		return getQuery(Specification.unrestricted(), Sort.unsorted()).getResultList();
 	}
 
 	@Override
@@ -424,30 +433,29 @@ public List<T> findAllById(Iterable<ID> ids) {
 
 		Collection<ID> idCollection = toCollection(ids);
 
-		ByIdsSpecification<T> specification = new ByIdsSpecification<>(entityInformation);
-		TypedQuery<T> query = getQuery(specification, Sort.unsorted());
+		TypedQuery<T> query = getQuery((root, q, criteriaBuilder) -> {
 
-		return query.setParameter(specification.parameter, idCollection).getResultList();
+			Path<?> path = root.get(entityInformation.getIdAttribute());
+			return path.in(idCollection);
+
+		}, Sort.unsorted());
+
+		return query.getResultList();
 	}
 
 	@Override
 	public List<T> findAll(Sort sort) {
-		return getQuery(null, sort).getResultList();
+		return getQuery(Specification.unrestricted(), sort).getResultList();
 	}
 
 	@Override
 	public Page<T> findAll(Pageable pageable) {
-		return findAll((Specification<T>) null, pageable);
+		return findAll(Specification.unrestricted(), pageable);
 	}
 
 	@Override
 	public Optional<T> findOne(Specification<T> spec) {
-
-		try {
-			return Optional.of(getQuery(spec, Sort.unsorted()).setMaxResults(2).getSingleResult());
-		} catch (NoResultException e) {
-			return Optional.empty();
-		}
+		return Optional.ofNullable(getQuery(spec, Sort.unsorted()).setMaxResults(2).getSingleResultOrNull());
 	}
 
 	@Override
@@ -456,7 +464,7 @@ public List<T> findAll(Specification<T> spec) {
 	}
 
 	@Override
-	public Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable) {
+	public Page<T> findAll(Specification<T> spec, Pageable pageable) {
 		return findAll(spec, spec, pageable);
 	}
 
@@ -469,13 +477,15 @@ public Page<T> findAll(@Nullable Specification<T> spec, @Nullable Specification<
 	}
 
 	@Override
-	public List<T> findAll(@Nullable Specification<T> spec, Sort sort) {
+	public List<T> findAll(Specification<T> spec, Sort sort) {
 		return getQuery(spec, sort).getResultList();
 	}
 
 	@Override
 	public boolean exists(Specification<T> spec) {
 
+		Assert.notNull(spec, "Specification must not be null");
+
 		CriteriaQuery<Integer> cq = this.entityManager.getCriteriaBuilder() //
 				.createQuery(Integer.class) //
 				.select(this.entityManager.getCriteriaBuilder().literal(1));
@@ -488,21 +498,20 @@ public boolean exists(Specification<T> spec) {
 
 	@Override
 	@Transactional
-	public long delete(@Nullable Specification<T> spec) {
+	public long update(UpdateSpecification<T> spec) {
 
-		CriteriaBuilder builder = this.entityManager.getCriteriaBuilder();
-		CriteriaDelete<T> delete = builder.createCriteriaDelete(getDomainClass());
+		Assert.notNull(spec, "Specification must not be null");
 
-		if (spec != null) {
-			Predicate predicate = spec.toPredicate(delete.from(getDomainClass()), builder.createQuery(getDomainClass()),
-					builder);
+		return getUpdate(spec, getDomainClass()).executeUpdate();
+	}
 
-			if (predicate != null) {
-				delete.where(predicate);
-			}
-		}
+	@Override
+	@Transactional
+	public long delete(DeleteSpecification<T> spec) {
 
-		return this.entityManager.createQuery(delete).executeUpdate();
+		Assert.notNull(spec, "Specification must not be null");
+
+		return getDelete(spec, getDomainClass()).executeUpdate();
 	}
 
 	@Override
@@ -515,6 +524,7 @@ public <S extends T, R> R findBy(Specification<T> spec,
 		return doFindBy(spec, getDomainClass(), queryFunction);
 	}
 
+	@SuppressWarnings("unchecked")
 	private <S extends T, R> R doFindBy(Specification<T> spec, Class<T> domainClass,
 			Function<? super SpecificationFluentQuery<S>, R> queryFunction) {
 
@@ -564,13 +574,10 @@ private <S extends T, R> R doFindBy(Specification<T> spec, Class<T> domainClass,
 	@Override
 	public <S extends T> Optional<S> findOne(Example<S> example) {
 
-		try {
-			return Optional
-					.of(getQuery(new ExampleSpecification<>(example, escapeCharacter), example.getProbeType(), Sort.unsorted())
-							.setMaxResults(2).getSingleResult());
-		} catch (NoResultException e) {
-			return Optional.empty();
-		}
+		TypedQuery<S> query = getQuery(new ExampleSpecification<>(example, escapeCharacter), example.getProbeType(),
+				Sort.unsorted()).setMaxResults(2);
+
+		return Optional.ofNullable(query.getSingleResultOrNull());
 	}
 
 	@Override
@@ -615,6 +622,7 @@ public <S extends T> Page<S> findAll(Example<S> example, Pageable pageable) {
 	}
 
 	@Override
+	@SuppressWarnings("unchecked")
 	public <S extends T, R> R findBy(Example<S> example, Function<FetchableFluentQuery<S>, R> queryFunction) {
 
 		Assert.notNull(example, EXAMPLE_MUST_NOT_BE_NULL);
@@ -637,7 +645,7 @@ public long count() {
 	}
 
 	@Override
-	public long count(@Nullable Specification<T> spec) {
+	public long count(Specification<T> spec) {
 		return executeCountQuery(getCountQuery(spec, getDomainClass()));
 	}
 
@@ -706,7 +714,7 @@ public void flush() {
 	 * @deprecated use {@link #readPage(TypedQuery, Class, Pageable, Specification)} instead
 	 */
 	@Deprecated
-	protected Page<T> readPage(TypedQuery<T> query, Pageable pageable, @Nullable Specification<T> spec) {
+	protected Page<T> readPage(TypedQuery<T> query, Pageable pageable, Specification<T> spec) {
 		return readPage(query, getDomainClass(), pageable, spec);
 	}
 
@@ -716,12 +724,15 @@ protected Page<T> readPage(TypedQuery<T> query, Pageable pageable, @Nullable Spe
 	 *
 	 * @param query must not be {@literal null}.
 	 * @param domainClass must not be {@literal null}.
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @param pageable can be {@literal null}.
 	 */
+	@Contract("_, _, _, null -> fail")
 	protected <S extends T> Page<S> readPage(TypedQuery<S> query, Class<S> domainClass, Pageable pageable,
 			@Nullable Specification<S> spec) {
 
+		Assert.notNull(spec, "Specification must not be null");
+
 		if (pageable.isPaged()) {
 			query.setFirstResult(PageableUtils.getOffsetAsInteger(pageable));
 			query.setMaxResults(pageable.getPageSize());
@@ -734,7 +745,7 @@ protected <S extends T> Page<S> readPage(TypedQuery<S> query, Class<S> domainCla
 	/**
 	 * Creates a new {@link TypedQuery} from the given {@link Specification}.
 	 *
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @param pageable must not be {@literal null}.
 	 */
 	protected TypedQuery<T> getQuery(@Nullable Specification<T> spec, Pageable pageable) {
@@ -744,29 +755,28 @@ protected TypedQuery<T> getQuery(@Nullable Specification<T> spec, Pageable pagea
 	/**
 	 * Creates a new {@link TypedQuery} from the given {@link Specification}.
 	 *
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @param domainClass must not be {@literal null}.
 	 * @param pageable must not be {@literal null}.
 	 */
-	protected <S extends T> TypedQuery<S> getQuery(@Nullable Specification<S> spec, Class<S> domainClass,
-			Pageable pageable) {
+	protected <S extends T> TypedQuery<S> getQuery(Specification<S> spec, Class<S> domainClass, Pageable pageable) {
 		return getQuery(spec, domainClass, pageable.getSort());
 	}
 
 	/**
 	 * Creates a {@link TypedQuery} for the given {@link Specification} and {@link Sort}.
 	 *
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @param sort must not be {@literal null}.
 	 */
-	protected TypedQuery<T> getQuery(@Nullable Specification<T> spec, Sort sort) {
+	protected TypedQuery<T> getQuery(Specification<T> spec, Sort sort) {
 		return getQuery(spec, getDomainClass(), sort);
 	}
 
 	/**
 	 * Creates a {@link TypedQuery} for the given {@link Specification} and {@link Sort}.
 	 *
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @param domainClass must not be {@literal null}.
 	 * @param sort must not be {@literal null}.
 	 */
@@ -788,6 +798,8 @@ protected <S extends T> TypedQuery<S> getQuery(@Nullable Specification<S> spec,
 	private <S extends T> TypedQuery<S> getQuery(ReturnedType returnedType, @Nullable Specification<S> spec,
 			Class<S> domainClass, Sort sort, Collection<String> inputProperties, @Nullable ScrollPosition scrollPosition) {
 
+		Assert.notNull(spec, "Specification must not be null");
+
 		CriteriaBuilder builder = entityManager.getCriteriaBuilder();
 		CriteriaQuery<S> query;
 
@@ -841,24 +853,62 @@ private <S extends T> TypedQuery<S> getQuery(ReturnedType returnedType, @Nullabl
 		return applyRepositoryMethodMetadata(entityManager.createQuery(query));
 	}
 
+	/**
+	 * Creates a {@link Query} for the given {@link UpdateSpecification}.
+	 *
+	 * @param spec must not be {@literal null}.
+	 * @param domainClass must not be {@literal null}.
+	 */
+	protected <S> Query getUpdate(UpdateSpecification<S> spec, Class<S> domainClass) {
+
+		Assert.notNull(spec, "Specification must not be null");
+
+		CriteriaBuilder builder = entityManager.getCriteriaBuilder();
+		CriteriaUpdate<S> query = builder.createCriteriaUpdate(domainClass);
+
+		applySpecificationToCriteria(spec, domainClass, query);
+
+		return applyRepositoryMethodMetadata(entityManager.createQuery(query));
+	}
+
+	/**
+	 * Creates a {@link Query} for the given {@link DeleteSpecification}.
+	 *
+	 * @param spec must not be {@literal null}.
+	 * @param domainClass must not be {@literal null}.
+	 */
+	protected <S> Query getDelete(DeleteSpecification<S> spec, Class<S> domainClass) {
+
+		Assert.notNull(spec, "Specification must not be null");
+
+		CriteriaBuilder builder = entityManager.getCriteriaBuilder();
+		CriteriaDelete<S> query = builder.createCriteriaDelete(domainClass);
+
+		applySpecificationToCriteria(spec, domainClass, query);
+
+		return applyRepositoryMethodMetadata(entityManager.createQuery(query));
+	}
+
 	/**
 	 * Creates a new count query for the given {@link Specification}.
 	 *
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @deprecated override {@link #getCountQuery(Specification, Class)} instead
 	 */
 	@Deprecated
-	protected TypedQuery<Long> getCountQuery(@Nullable Specification<T> spec) {
+	protected TypedQuery<Long> getCountQuery(Specification<T> spec) {
 		return getCountQuery(spec, getDomainClass());
 	}
 
 	/**
 	 * Creates a new count query for the given {@link Specification}.
 	 *
-	 * @param spec can be {@literal null}.
+	 * @param spec must not be {@literal null}.
 	 * @param domainClass must not be {@literal null}.
 	 */
-	protected <S extends T> TypedQuery<Long> getCountQuery(@Nullable Specification<S> spec, Class<S> domainClass) {
+	protected <S extends T> TypedQuery<Long> getCountQuery(Specification<S> spec, Class<S> domainClass) {
+
+		Assert.notNull(spec, "Specification must not be null");
 
 		CriteriaBuilder builder = entityManager.getCriteriaBuilder();
 		CriteriaQuery<Long> query = builder.createQuery(Long.class);
@@ -892,33 +942,45 @@ protected QueryHints getQueryHintsForCount() {
 		return metadata == null ? NoHints.INSTANCE : DefaultQueryHints.of(entityInformation, metadata).forCounts();
 	}
 
-	/**
-	 * Applies the given {@link Specification} to the given {@link CriteriaQuery}.
-	 *
-	 * @param spec can be {@literal null}.
-	 * @param domainClass must not be {@literal null}.
-	 * @param query must not be {@literal null}.
-	 */
-	private <S, U extends T> Root<U> applySpecificationToCriteria(@Nullable Specification<U> spec, Class<U> domainClass,
+	private <S, U extends T> Root<U> applySpecificationToCriteria(Specification<U> spec, Class<U> domainClass,
 			CriteriaQuery<S> query) {
 
-		Assert.notNull(domainClass, "Domain class must not be null");
-		Assert.notNull(query, "CriteriaQuery must not be null");
-
 		Root<U> root = query.from(domainClass);
 
-		if (spec == null) {
-			return root;
+		CriteriaBuilder builder = entityManager.getCriteriaBuilder();
+		Predicate predicate = spec.toPredicate(root, query, builder);
+
+		if (predicate != null) {
+			query.where(predicate);
 		}
 
+		return root;
+	}
+
+	private <S> void applySpecificationToCriteria(UpdateSpecification<S> spec, Class<S> domainClass,
+			CriteriaUpdate<S> query) {
+
+		Root<S> root = query.from(domainClass);
+
 		CriteriaBuilder builder = entityManager.getCriteriaBuilder();
 		Predicate predicate = spec.toPredicate(root, query, builder);
 
 		if (predicate != null) {
 			query.where(predicate);
 		}
+	}
 
-		return root;
+	private <S> void applySpecificationToCriteria(DeleteSpecification<S> spec, Class<S> domainClass,
+			CriteriaDelete<S> query) {
+
+		Root<S> root = query.from(domainClass);
+
+		CriteriaBuilder builder = entityManager.getCriteriaBuilder();
+		Predicate predicate = spec.toPredicate(root, query, builder);
+
+		if (predicate != null) {
+			query.where(predicate);
+		}
 	}
 
 	private <S> TypedQuery<S> applyRepositoryMethodMetadata(TypedQuery<S> query) {
@@ -935,6 +997,20 @@ private <S> TypedQuery<S> applyRepositoryMethodMetadata(TypedQuery<S> query) {
 		return toReturn;
 	}
 
+	private Query applyRepositoryMethodMetadata(Query query) {
+
+		if (metadata == null) {
+			return query;
+		}
+
+		LockModeType type = metadata.getLockModeType();
+		Query toReturn = type == null ? query : query.setLockMode(type);
+
+		applyQueryHints(toReturn);
+
+		return toReturn;
+	}
+
 	private void applyQueryHints(Query query) {
 
 		if (metadata == null) {
@@ -982,7 +1058,7 @@ private Map<String, Object> getHints() {
 	private void applyComment(CrudMethodMetadata metadata, BiConsumer<String, Object> consumer) {
 
 		if (metadata.getComment() != null && provider.getCommentHintKey() != null) {
-			consumer.accept(provider.getCommentHintKey(), provider.getCommentHintValue(this.metadata.getComment()));
+			consumer.accept(provider.getCommentHintKey(), provider.getCommentHintValue(metadata.getComment()));
 		}
 	}
 
@@ -1019,36 +1095,6 @@ private static long executeCountQuery(TypedQuery<Long> query) {
 		return total;
 	}
 
-	/**
-	 * Specification that gives access to the {@link Parameter} instance used to bind the ids for
-	 * {@link SimpleJpaRepository#findAllById(Iterable)}. Workaround for OpenJPA not binding collections to in-clauses
-	 * correctly when using by-name binding.
-	 *
-	 * @author Oliver Gierke
-	 * @see <a href="https://issues.apache.org/jira/browse/OPENJPA-2018?focusedCommentId=13924055">OPENJPA-2018</a>
-	 */
-	@SuppressWarnings("rawtypes")
-	private static final class ByIdsSpecification<T> implements Specification<T> {
-
-		@Serial private static final long serialVersionUID = 1L;
-
-		private final JpaEntityInformation<T, ?> entityInformation;
-
-		@Nullable ParameterExpression<Collection<?>> parameter;
-
-		ByIdsSpecification(JpaEntityInformation<T, ?> entityInformation) {
-			this.entityInformation = entityInformation;
-		}
-
-		@Override
-		public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
-
-			Path<?> path = root.get(entityInformation.getIdAttribute());
-			parameter = (ParameterExpression<Collection<?>>) (ParameterExpression) cb.parameter(Collection.class);
-			return path.in(parameter);
-		}
-	}
-
 	/**
 	 * {@link Specification} that gives access to the {@link Predicate} instance representing the values contained in the
 	 * {@link Example}.
@@ -1057,12 +1103,8 @@ public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuild
 	 * @author Christoph Strobl
 	 * @since 1.10
 	 */
-	private static class ExampleSpecification<T> implements Specification<T> {
-
-		@Serial private static final long serialVersionUID = 1L;
-
-		private final Example<T> example;
-		private final EscapeCharacter escapeCharacter;
+	private record ExampleSpecification<T>(Example<T> example,
+			EscapeCharacter escapeCharacter) implements Specification<T> {
 
 		/**
 		 * Creates new {@link ExampleSpecification}.
@@ -1070,17 +1112,15 @@ private static class ExampleSpecification<T> implements Specification<T> {
 		 * @param example the example to base the specification of. Must not be {@literal null}.
 		 * @param escapeCharacter the escape character to use for like expressions. Must not be {@literal null}.
 		 */
-		ExampleSpecification(Example<T> example, EscapeCharacter escapeCharacter) {
+		private ExampleSpecification {
 
 			Assert.notNull(example, EXAMPLE_MUST_NOT_BE_NULL);
 			Assert.notNull(escapeCharacter, "EscapeCharacter must not be null");
 
-			this.example = example;
-			this.escapeCharacter = escapeCharacter;
 		}
 
 		@Override
-		public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
+		public @Nullable Predicate toPredicate(Root<T> root, @Nullable CriteriaQuery<?> query, CriteriaBuilder cb) {
 			return QueryByExamplePredicateBuilder.getPredicate(root, cb, example, escapeCharacter);
 		}
 	}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java
index 2ee289253a..d5d518d004 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java
@@ -22,8 +22,6 @@
 import java.util.Collection;
 import java.util.Map;
 
-import org.springframework.lang.Nullable;
-
 import com.querydsl.core.QueryModifiers;
 import com.querydsl.core.types.Expression;
 import com.querydsl.core.types.FactoryExpression;
@@ -31,6 +29,7 @@
 import com.querydsl.jpa.JPQLTemplates;
 import com.querydsl.jpa.impl.JPAQuery;
 import com.querydsl.jpa.impl.JPAUtil;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Customized String-Query implementation that specifically routes tuple query creation to
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/package-info.java
index 2f75e71375..c40a1ae92f 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/package-info.java
@@ -1,5 +1,5 @@
 /**
  * JPA repository implementations.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.repository.support;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessor.java
index 324c37f327..686d2ab7ed 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessor.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessor.java
@@ -26,6 +26,8 @@
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.beans.factory.config.BeanDefinition;
 import org.springframework.context.EnvironmentAware;
 import org.springframework.context.ResourceLoaderAware;
@@ -39,7 +41,6 @@
 import org.springframework.core.io.support.ResourcePatternResolver;
 import org.springframework.core.io.support.ResourcePatternUtils;
 import org.springframework.core.type.filter.AnnotationTypeFilter;
-import org.springframework.lang.Nullable;
 import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo;
 import org.springframework.orm.jpa.persistenceunit.PersistenceUnitPostProcessor;
 import org.springframework.util.Assert;
@@ -193,7 +194,7 @@ private Set<String> scanForMappingFileLocations() {
 	 * @param uri
 	 * @return
 	 */
-	private static String getResourcePath(URI uri) throws IOException {
+	private static String getResourcePath(URI uri) {
 
 		if (uri.isOpaque()) {
 			// e.g. jar:file:/foo/lib/somelib.jar!/com/acme/orm.xml
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/package-info.java
index ad7b5e7f45..6e60ae77b4 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Various helper classes useful when working with JPA.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.support;
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/HibernateProxyDetector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/HibernateProxyDetector.java
index 149742c0b7..2caa4ea9a8 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/HibernateProxyDetector.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/HibernateProxyDetector.java
@@ -18,8 +18,9 @@
 import java.util.Optional;
 
 import org.hibernate.proxy.HibernateProxy;
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.util.ProxyUtils.ProxyDetector;
-import org.springframework.lang.Nullable;
 import org.springframework.util.ClassUtils;
 
 /**
@@ -40,8 +41,7 @@ public Class<?> getUserType(Class<?> type) {
 				.orElse(type);
 	}
 
-	@Nullable
-	private static Class<?> loadHibernateProxyType() {
+	private static @Nullable Class<?> loadHibernateProxyType() {
 
 		try {
 			return ClassUtils.forName("org.hibernate.proxy.HibernateProxy", HibernateProxyDetector.class.getClassLoader());
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/TupleBackedMap.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/TupleBackedMap.java
new file mode 100644
index 0000000000..1c6c6927f7
--- /dev/null
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/TupleBackedMap.java
@@ -0,0 +1,219 @@
+/*
+ * Copyright 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.springframework.data.jpa.util;
+
+import jakarta.persistence.Tuple;
+import jakarta.persistence.TupleElement;
+
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.UnaryOperator;
+import java.util.stream.Collectors;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.jdbc.support.JdbcUtils;
+
+/**
+ * A {@link Map} implementation which delegates all calls to a {@link Tuple}. Depending on the provided {@link Tuple}
+ * implementation it might return the same value for various keys of which only one will appear in the key/entry set.
+ *
+ * @author Jens Schauder
+ * @since 4.0
+ */
+public class TupleBackedMap implements Map<String, Object> {
+
+	private static final String UNMODIFIABLE_MESSAGE = "A TupleBackedMap cannot be modified";
+
+	private final Tuple tuple;
+
+	public TupleBackedMap(Tuple tuple) {
+		this.tuple = tuple;
+	}
+
+	/**
+	 * Creates a underscore-aware {@link Tuple} wrapper applying {@link JdbcUtils#convertPropertyNameToUnderscoreName}
+	 * conversion to leniently look up properties from query results whose columns follow snake-case syntax.
+	 *
+	 * @param delegate the tuple to wrap.
+	 * @return
+	 */
+	public static Tuple underscoreAware(Tuple delegate) {
+		return new FallbackTupleWrapper(delegate);
+	}
+
+	@Override
+	public int size() {
+		return tuple.getElements().size();
+	}
+
+	@Override
+	public boolean isEmpty() {
+		return tuple.getElements().isEmpty();
+	}
+
+	/**
+	 * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code false}. Otherwise
+	 * this returns {@code true} even when the value from the backing {@code Tuple} is {@code null}.
+	 *
+	 * @param key the key for which to get the value from the map.
+	 * @return whether the key is an element of the backing tuple.
+	 */
+	@Override
+	public boolean containsKey(Object key) {
+
+		try {
+			tuple.get((String) key);
+			return true;
+		} catch (IllegalArgumentException e) {
+			return false;
+		}
+	}
+
+	@Override
+	public boolean containsValue(Object value) {
+		return Arrays.asList(tuple.toArray()).contains(value);
+	}
+
+	/**
+	 * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code null}. Otherwise
+	 * the value from the backing {@code Tuple} is returned, which also might be {@code null}.
+	 *
+	 * @param key the key for which to get the value from the map.
+	 * @return the value of the backing {@link Tuple} for that key or {@code null}.
+	 */
+	@Override
+	public @Nullable Object get(Object key) {
+
+		if (!(key instanceof String)) {
+			return null;
+		}
+
+		try {
+			return tuple.get((String) key);
+		} catch (IllegalArgumentException e) {
+			return null;
+		}
+	}
+
+	@Override
+	public Object put(String key, Object value) {
+		throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE);
+	}
+
+	@Override
+	public Object remove(Object key) {
+		throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE);
+	}
+
+	@Override
+	public void putAll(Map<? extends String, ?> m) {
+		throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE);
+	}
+
+	@Override
+	public void clear() {
+		throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE);
+	}
+
+	@Override
+	public Set<String> keySet() {
+
+		return tuple.getElements().stream() //
+				.map(TupleElement::getAlias) //
+				.collect(Collectors.toSet());
+	}
+
+	@Override
+	public Collection<Object> values() {
+		return Arrays.asList(tuple.toArray());
+	}
+
+	@Override
+	public Set<Entry<String, Object>> entrySet() {
+
+		return tuple.getElements().stream() //
+				.map(e -> new HashMap.SimpleEntry<String, Object>(e.getAlias(), tuple.get(e))) //
+				.collect(Collectors.toSet());
+	}
+
+	static class FallbackTupleWrapper implements Tuple {
+
+		private final Tuple delegate;
+		private final UnaryOperator<String> fallbackNameTransformer = JdbcUtils::convertPropertyNameToUnderscoreName;
+
+		FallbackTupleWrapper(Tuple delegate) {
+			this.delegate = delegate;
+		}
+
+		@Override
+		public <X> X get(TupleElement<X> tupleElement) {
+			return get(tupleElement.getAlias(), tupleElement.getJavaType());
+		}
+
+		@Override
+		public <X> X get(String s, Class<X> type) {
+			try {
+				return delegate.get(s, type);
+			} catch (IllegalArgumentException original) {
+				try {
+					return delegate.get(fallbackNameTransformer.apply(s), type);
+				} catch (IllegalArgumentException next) {
+					original.addSuppressed(next);
+					throw original;
+				}
+			}
+		}
+
+		@Override
+		public Object get(String s) {
+			try {
+				return delegate.get(s);
+			} catch (IllegalArgumentException original) {
+				try {
+					return delegate.get(fallbackNameTransformer.apply(s));
+				} catch (IllegalArgumentException next) {
+					original.addSuppressed(next);
+					throw original;
+				}
+			}
+		}
+
+		@Override
+		public <X> X get(int i, Class<X> aClass) {
+			return delegate.get(i, aClass);
+		}
+
+		@Override
+		public Object get(int i) {
+			return delegate.get(i);
+		}
+
+		@Override
+		public Object[] toArray() {
+			return delegate.toArray();
+		}
+
+		@Override
+		public List<TupleElement<?>> getElements() {
+			return delegate.getElements();
+		}
+	}
+}
diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/package-info.java
index f49bdb7cc1..264664d04e 100644
--- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/package-info.java
+++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/package-info.java
@@ -1,5 +1,5 @@
 /**
  * Spring Data JPA utilities.
  */
-@org.springframework.lang.NonNullApi
+@org.jspecify.annotations.NullMarked
 package org.springframework.data.jpa.util;
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/AntlrVersionTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/AntlrVersionTests.java
index 7c18f5d466..489f29326e 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/AntlrVersionTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/AntlrVersionTests.java
@@ -22,6 +22,7 @@
 
 import org.antlr.v4.runtime.RuntimeMetaData;
 import org.hibernate.grammars.hql.HqlParser;
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.Test;
 
 import org.springframework.asm.ClassReader;
@@ -29,7 +30,6 @@
 import org.springframework.asm.MethodVisitor;
 import org.springframework.asm.Opcodes;
 import org.springframework.data.jpa.util.DisabledOnHibernate;
-import org.springframework.lang.Nullable;
 
 /**
  * Test to verify that we use the same Antlr version as Hibernate. We parse {@code org.hibernate.grammars.hql.HqlParser}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/DeleteSpecificationUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/DeleteSpecificationUnitTests.java
new file mode 100644
index 0000000000..8dfcb33bad
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/DeleteSpecificationUnitTests.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2024 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.springframework.data.jpa.domain;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.util.SerializationUtils.*;
+
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaDelete;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+
+import java.io.Serializable;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+/**
+ * Unit tests for {@link DeleteSpecification}.
+ *
+ * @author Mark Paluch
+ */
+@SuppressWarnings("serial")
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class DeleteSpecificationUnitTests implements Serializable {
+
+	private DeleteSpecification<Object> spec;
+	@Mock(serializable = true) Root<Object> root;
+	@Mock(serializable = true) CriteriaDelete<Object> delete;
+	@Mock(serializable = true) CriteriaBuilder builder;
+	@Mock(serializable = true) Predicate predicate;
+	@Mock(serializable = true) Predicate another;
+
+	@BeforeEach
+	void setUp() {
+		spec = (root, delete, cb) -> predicate;
+	}
+
+	@Test // GH-3521
+	void allReturnsEmptyPredicate() {
+
+		DeleteSpecification<Object> specification = DeleteSpecification.unrestricted();
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, delete, builder)).isNull();
+	}
+
+	@Test // GH-3521
+	void allOfCombinesPredicatesInOrder() {
+
+		DeleteSpecification<Object> specification = DeleteSpecification.allOf(spec);
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, delete, builder)).isSameAs(predicate);
+	}
+
+	@Test // GH-3521
+	void anyOfCombinesPredicatesInOrder() {
+
+		DeleteSpecification<Object> specification = DeleteSpecification.allOf(spec);
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, delete, builder)).isSameAs(predicate);
+	}
+
+	@Test // GH-3521
+	void emptyAllOfReturnsEmptySpecification() {
+
+		DeleteSpecification<Object> specification = DeleteSpecification.allOf();
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, delete, builder)).isNull();
+	}
+
+	@Test // GH-3521
+	void emptyAnyOfReturnsEmptySpecification() {
+
+		DeleteSpecification<Object> specification = DeleteSpecification.anyOf();
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, delete, builder)).isNull();
+	}
+
+	@Test // GH-3521
+	void specificationsShouldBeSerializable() {
+
+		DeleteSpecification<Object> serializableSpec = new SerializableSpecification();
+		DeleteSpecification<Object> specification = serializableSpec.and(serializableSpec);
+
+		assertThat(specification).isNotNull();
+
+		@SuppressWarnings("unchecked")
+		DeleteSpecification<Object> transferredSpecification = (DeleteSpecification<Object>) deserialize(
+				serialize(specification));
+
+		assertThat(transferredSpecification).isNotNull();
+	}
+
+	@Test // GH-3521
+	void complexSpecificationsShouldBeSerializable() {
+
+		SerializableSpecification serializableSpec = new SerializableSpecification();
+		DeleteSpecification<Object> specification = DeleteSpecification
+				.not(serializableSpec.and(serializableSpec).or(serializableSpec));
+
+		assertThat(specification).isNotNull();
+
+		@SuppressWarnings("unchecked")
+		DeleteSpecification<Object> transferredSpecification = (DeleteSpecification<Object>) deserialize(
+				serialize(specification));
+
+		assertThat(transferredSpecification).isNotNull();
+	}
+
+	@Test // GH-3521
+	void andCombinesSpecificationsInOrder() {
+
+		Predicate firstPredicate = mock(Predicate.class);
+		Predicate secondPredicate = mock(Predicate.class);
+
+		DeleteSpecification<Object> first = ((root1, delete, criteriaBuilder) -> firstPredicate);
+		DeleteSpecification<Object> second = ((root1, delete, criteriaBuilder) -> secondPredicate);
+
+		first.and(second).toPredicate(root, delete, builder);
+
+		verify(builder).and(firstPredicate, secondPredicate);
+	}
+
+	@Test // GH-3521
+	void orCombinesSpecificationsInOrder() {
+
+		Predicate firstPredicate = mock(Predicate.class);
+		Predicate secondPredicate = mock(Predicate.class);
+
+		DeleteSpecification<Object> first = ((root1, delete, criteriaBuilder) -> firstPredicate);
+		DeleteSpecification<Object> second = ((root1, delete, criteriaBuilder) -> secondPredicate);
+
+		first.or(second).toPredicate(root, delete, builder);
+
+		verify(builder).or(firstPredicate, secondPredicate);
+	}
+
+	@Test // GH-3849
+	void notWithNullPredicate() {
+
+		when(builder.disjunction()).thenReturn(mock(Predicate.class));
+
+		DeleteSpecification<Object> notSpec = DeleteSpecification.not((r, q, cb) -> null);
+
+		assertThat(notSpec.toPredicate(root, delete, builder)).isNotNull();
+		verify(builder).disjunction();
+	}
+
+	static class SerializableSpecification implements Serializable, DeleteSpecification<Object> {
+
+		@Override
+		public Predicate toPredicate(Root<Object> root, CriteriaDelete<Object> delete, CriteriaBuilder cb) {
+			return null;
+		}
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/JpaSortTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/JpaSortTests.java
index dac929f40d..c64d55f7f7 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/JpaSortTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/JpaSortTests.java
@@ -23,6 +23,7 @@
 import jakarta.persistence.metamodel.Attribute;
 import jakarta.persistence.metamodel.PluralAttribute;
 
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 
@@ -30,7 +31,6 @@
 import org.springframework.data.jpa.domain.sample.MailMessage_;
 import org.springframework.data.jpa.domain.sample.MailSender_;
 import org.springframework.data.jpa.domain.sample.User_;
-import org.springframework.lang.Nullable;
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/PredicateSpecificationUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/PredicateSpecificationUnitTests.java
new file mode 100644
index 0000000000..d11d61d0a2
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/PredicateSpecificationUnitTests.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2024 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.springframework.data.jpa.domain;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.util.SerializationUtils.*;
+
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+
+import java.io.Serializable;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+/**
+ * Unit tests for {@link PredicateSpecification}.
+ *
+ * @author Mark Paluch
+ */
+@SuppressWarnings("serial")
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class PredicateSpecificationUnitTests implements Serializable {
+
+	private PredicateSpecification<Object> spec;
+	@Mock(serializable = true) Root<Object> root;
+	@Mock(serializable = true) CriteriaBuilder builder;
+	@Mock(serializable = true) Predicate predicate;
+	@Mock(serializable = true) Predicate another;
+
+	@BeforeEach
+	void setUp() {
+		spec = (root, cb) -> predicate;
+	}
+
+	@Test // GH-3521
+	void allReturnsEmptyPredicate() {
+
+		PredicateSpecification<Object> specification = PredicateSpecification.unrestricted();
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, builder)).isNull();
+	}
+
+	@Test // GH-3521
+	void allOfCombinesPredicatesInOrder() {
+
+		PredicateSpecification<Object> specification = PredicateSpecification.allOf(spec);
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, builder)).isSameAs(predicate);
+	}
+
+	@Test // GH-3521
+	void anyOfCombinesPredicatesInOrder() {
+
+		PredicateSpecification<Object> specification = PredicateSpecification.allOf(spec);
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, builder)).isSameAs(predicate);
+	}
+
+	@Test // GH-3521
+	void emptyAllOfReturnsEmptySpecification() {
+
+		PredicateSpecification<Object> specification = PredicateSpecification.allOf();
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, builder)).isNull();
+	}
+
+	@Test // GH-3521
+	void emptyAnyOfReturnsEmptySpecification() {
+
+		PredicateSpecification<Object> specification = PredicateSpecification.anyOf();
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, builder)).isNull();
+	}
+
+	@Test // GH-3521
+	void specificationsShouldBeSerializable() {
+
+		PredicateSpecification<Object> serializableSpec = new SerializableSpecification();
+		PredicateSpecification<Object> specification = serializableSpec.and(serializableSpec);
+
+		assertThat(specification).isNotNull();
+
+		@SuppressWarnings("unchecked")
+		PredicateSpecification<Object> transferredSpecification = (PredicateSpecification<Object>) deserialize(
+				serialize(specification));
+
+		assertThat(transferredSpecification).isNotNull();
+	}
+
+	@Test // GH-3521
+	void complexSpecificationsShouldBeSerializable() {
+
+		SerializableSpecification serializableSpec = new SerializableSpecification();
+		PredicateSpecification<Object> specification = PredicateSpecification
+				.not(serializableSpec.and(serializableSpec).or(serializableSpec));
+
+		assertThat(specification).isNotNull();
+
+		@SuppressWarnings("unchecked")
+		PredicateSpecification<Object> transferredSpecification = (PredicateSpecification<Object>) deserialize(
+				serialize(specification));
+
+		assertThat(transferredSpecification).isNotNull();
+	}
+
+	@Test // GH-3521
+	void andCombinesSpecificationsInOrder() {
+
+		Predicate firstPredicate = mock(Predicate.class);
+		Predicate secondPredicate = mock(Predicate.class);
+
+		PredicateSpecification<Object> first = ((root1, criteriaBuilder) -> firstPredicate);
+		PredicateSpecification<Object> second = ((root1, criteriaBuilder) -> secondPredicate);
+
+		first.and(second).toPredicate(root, builder);
+
+		verify(builder).and(firstPredicate, secondPredicate);
+	}
+
+	@Test // GH-3521
+	void orCombinesSpecificationsInOrder() {
+
+		Predicate firstPredicate = mock(Predicate.class);
+		Predicate secondPredicate = mock(Predicate.class);
+
+		PredicateSpecification<Object> first = ((root1, criteriaBuilder) -> firstPredicate);
+		PredicateSpecification<Object> second = ((root1, criteriaBuilder) -> secondPredicate);
+
+		first.or(second).toPredicate(root, builder);
+
+		verify(builder).or(firstPredicate, secondPredicate);
+	}
+
+	@Test // GH-3849
+	void notWithNullPredicate() {
+
+		when(builder.disjunction()).thenReturn(mock(Predicate.class));
+
+		PredicateSpecification<Object> notSpec = PredicateSpecification.not((r, cb) -> null);
+
+		assertThat(notSpec.toPredicate(root, builder)).isNotNull();
+		verify(builder).disjunction();
+	}
+
+	static class SerializableSpecification implements Serializable, PredicateSpecification<Object> {
+
+		@Override
+		public Predicate toPredicate(Root<Object> root, CriteriaBuilder cb) {
+			return null;
+		}
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/SpecificationUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/SpecificationUnitTests.java
index 368ccc7ff5..8380816d52 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/SpecificationUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/SpecificationUnitTests.java
@@ -17,8 +17,6 @@
 
 import static org.assertj.core.api.Assertions.*;
 import static org.mockito.Mockito.*;
-import static org.springframework.data.jpa.domain.Specification.*;
-import static org.springframework.data.jpa.domain.Specification.not;
 import static org.springframework.util.SerializationUtils.*;
 
 import jakarta.persistence.criteria.CriteriaBuilder;
@@ -64,79 +62,6 @@ void setUp() {
 		spec = (root, query, cb) -> predicate;
 	}
 
-	@Test // DATAJPA-300, DATAJPA-1170
-	void createsSpecificationsFromNull() {
-
-		Specification<Object> specification = where(null);
-		assertThat(specification).isNotNull();
-		assertThat(specification.toPredicate(root, query, builder)).isNull();
-	}
-
-	@Test // DATAJPA-300, DATAJPA-1170
-	void negatesNullSpecToNull() {
-
-		Specification<Object> specification = not(null);
-
-		assertThat(specification).isNotNull();
-		assertThat(specification.toPredicate(root, query, builder)).isNull();
-	}
-
-	@Test // DATAJPA-300, DATAJPA-1170
-	void andConcatenatesSpecToNullSpec() {
-
-		Specification<Object> specification = where(null);
-		specification = specification.and(spec);
-
-		assertThat(specification).isNotNull();
-		assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate);
-	}
-
-	@Test // DATAJPA-300, DATAJPA-1170
-	void andConcatenatesNullSpecToSpec() {
-
-		Specification<Object> specification = spec.and(null);
-
-		assertThat(specification).isNotNull();
-		assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate);
-	}
-
-	@Test // DATAJPA-300, DATAJPA-1170
-	void orConcatenatesSpecToNullSpec() {
-
-		Specification<Object> specification = where(null);
-		specification = specification.or(spec);
-
-		assertThat(specification).isNotNull();
-		assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate);
-	}
-
-	@Test // DATAJPA-300, DATAJPA-1170
-	void orConcatenatesNullSpecToSpec() {
-
-		Specification<Object> specification = spec.or(null);
-
-		assertThat(specification).isNotNull();
-		assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate);
-	}
-
-	@Test // GH-1943
-	void allOfConcatenatesNull() {
-
-		Specification<Object> specification = Specification.allOf(null, spec, null);
-
-		assertThat(specification).isNotNull();
-		assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate);
-	}
-
-	@Test // GH-1943
-	void anyOfConcatenatesNull() {
-
-		Specification<Object> specification = Specification.anyOf(null, spec, null);
-
-		assertThat(specification).isNotNull();
-		assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate);
-	}
-
 	@Test // GH-1943
 	void emptyAllOfReturnsEmptySpecification() {
 
@@ -163,7 +88,7 @@ void specificationsShouldBeSerializable() {
 
 		assertThat(specification).isNotNull();
 
-		@SuppressWarnings({ "unchecked", "deprecation" })
+		@SuppressWarnings({ "unchecked", "deprecation"})
 		Specification<Object> transferredSpecification = (Specification<Object>) deserialize(serialize(specification));
 
 		assertThat(transferredSpecification).isNotNull();
@@ -178,7 +103,7 @@ void complexSpecificationsShouldBeSerializable() {
 
 		assertThat(specification).isNotNull();
 
-		@SuppressWarnings({ "unchecked", "deprecation" })
+		@SuppressWarnings({ "unchecked", "deprecation"})
 		Specification<Object> transferredSpecification = (Specification<Object>) deserialize(serialize(specification));
 
 		assertThat(transferredSpecification).isNotNull();
@@ -206,7 +131,6 @@ void orCombinesSpecificationsInOrder() {
 		Predicate secondPredicate = mock(Predicate.class);
 
 		Specification<Object> first = ((root1, query1, criteriaBuilder) -> firstPredicate);
-
 		Specification<Object> second = ((root1, query1, criteriaBuilder) -> secondPredicate);
 
 		first.or(second).toPredicate(root, query, builder);
@@ -214,6 +138,17 @@ void orCombinesSpecificationsInOrder() {
 		verify(builder).or(firstPredicate, secondPredicate);
 	}
 
+	@Test // GH-3849
+	void notWithNullPredicate() {
+
+		when(builder.disjunction()).thenReturn(mock(Predicate.class));
+
+		Specification<Object> notSpec = Specification.not((r, q, cb) -> null);
+
+		assertThat(notSpec.toPredicate(root, query, builder)).isNotNull();
+		verify(builder).disjunction();
+	}
+
 	static class SerializableSpecification implements Serializable, Specification<Object> {
 
 		@Override
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/UpdateSpecificationUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/UpdateSpecificationUnitTests.java
new file mode 100644
index 0000000000..61c788d143
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/UpdateSpecificationUnitTests.java
@@ -0,0 +1,181 @@
+/*
+ * Copyright 2024 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.springframework.data.jpa.domain;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+import static org.springframework.util.SerializationUtils.*;
+
+import jakarta.persistence.criteria.CriteriaBuilder;
+import jakarta.persistence.criteria.CriteriaUpdate;
+import jakarta.persistence.criteria.Predicate;
+import jakarta.persistence.criteria.Root;
+
+import java.io.Serializable;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+/**
+ * Unit tests for {@link UpdateSpecification}.
+ *
+ * @author Mark Paluch
+ */
+@SuppressWarnings("serial")
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class UpdateSpecificationUnitTests implements Serializable {
+
+	private UpdateSpecification<Object> spec;
+	@Mock(serializable = true) Root<Object> root;
+	@Mock(serializable = true) CriteriaUpdate<Object> update;
+	@Mock(serializable = true) CriteriaBuilder builder;
+	@Mock(serializable = true) Predicate predicate;
+	@Mock(serializable = true) Predicate another;
+
+	@BeforeEach
+	void setUp() {
+		spec = (root, update, cb) -> predicate;
+	}
+
+	@Test // GH-3521
+	void allReturnsEmptyPredicate() {
+
+		UpdateSpecification<Object> specification = UpdateSpecification.unrestricted();
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, update, builder)).isNull();
+	}
+
+	@Test // GH-3521
+	void allOfCombinesPredicatesInOrder() {
+
+		UpdateSpecification<Object> specification = UpdateSpecification.allOf(spec);
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, update, builder)).isSameAs(predicate);
+	}
+
+	@Test // GH-3521
+	void anyOfCombinesPredicatesInOrder() {
+
+		UpdateSpecification<Object> specification = UpdateSpecification.allOf(spec);
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, update, builder)).isSameAs(predicate);
+	}
+
+	@Test // GH-3521
+	void emptyAllOfReturnsEmptySpecification() {
+
+		UpdateSpecification<Object> specification = UpdateSpecification.allOf();
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, update, builder)).isNull();
+	}
+
+	@Test // GH-3521
+	void emptyAnyOfReturnsEmptySpecification() {
+
+		UpdateSpecification<Object> specification = UpdateSpecification.anyOf();
+
+		assertThat(specification).isNotNull();
+		assertThat(specification.toPredicate(root, update, builder)).isNull();
+	}
+
+	@Test // GH-3521
+	void specificationsShouldBeSerializable() {
+
+		UpdateSpecification<Object> serializableSpec = new SerializableSpecification();
+		UpdateSpecification<Object> specification = serializableSpec.and(serializableSpec);
+
+		assertThat(specification).isNotNull();
+
+		@SuppressWarnings("unchecked")
+		UpdateSpecification<Object> transferredSpecification = (UpdateSpecification<Object>) deserialize(
+				serialize(specification));
+
+		assertThat(transferredSpecification).isNotNull();
+	}
+
+	@Test // GH-3521
+	void complexSpecificationsShouldBeSerializable() {
+
+		SerializableSpecification serializableSpec = new SerializableSpecification();
+		UpdateSpecification<Object> specification = UpdateSpecification
+				.not(serializableSpec.and(serializableSpec).or(serializableSpec));
+
+		assertThat(specification).isNotNull();
+
+		@SuppressWarnings("unchecked")
+		UpdateSpecification<Object> transferredSpecification = (UpdateSpecification<Object>) deserialize(
+				serialize(specification));
+
+		assertThat(transferredSpecification).isNotNull();
+	}
+
+	@Test // GH-3521
+	void andCombinesSpecificationsInOrder() {
+
+		Predicate firstPredicate = mock(Predicate.class);
+		Predicate secondPredicate = mock(Predicate.class);
+
+		UpdateSpecification<Object> first = ((root1, update, criteriaBuilder) -> firstPredicate);
+		UpdateSpecification<Object> second = ((root1, update, criteriaBuilder) -> secondPredicate);
+
+		first.and(second).toPredicate(root, update, builder);
+
+		verify(builder).and(firstPredicate, secondPredicate);
+	}
+
+	@Test // GH-3521
+	void orCombinesSpecificationsInOrder() {
+
+		Predicate firstPredicate = mock(Predicate.class);
+		Predicate secondPredicate = mock(Predicate.class);
+
+		UpdateSpecification<Object> first = ((root1, update, criteriaBuilder) -> firstPredicate);
+		UpdateSpecification<Object> second = ((root1, update, criteriaBuilder) -> secondPredicate);
+
+		first.or(second).toPredicate(root, update, builder);
+
+		verify(builder).or(firstPredicate, secondPredicate);
+	}
+
+	@Test // GH-3849
+	void notWithNullPredicate() {
+
+		when(builder.disjunction()).thenReturn(mock(Predicate.class));
+
+		UpdateSpecification<Object> notSpec = UpdateSpecification.not((r, q, cb) -> null);
+
+		assertThat(notSpec.toPredicate(root, update, builder)).isNotNull();
+		verify(builder).disjunction();
+	}
+
+	static class SerializableSpecification implements Serializable, UpdateSpecification<Object> {
+
+		@Override
+		public Predicate toPredicate(Root<Object> root, CriteriaUpdate<Object> update, CriteriaBuilder cb) {
+			return null;
+		}
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Address.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Address.java
index e5db7bfddf..ccd97f7b74 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Address.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Address.java
@@ -17,6 +17,8 @@
 
 import jakarta.persistence.Embeddable;
 
+import org.springframework.util.ObjectUtils;
+
 /**
  * @author Thomas Darimont
  */
@@ -52,4 +54,26 @@ public String getStreetName() {
 	public String getStreetNo() {
 		return streetNo;
 	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (!(o instanceof Address address)) {
+			return false;
+		}
+		if (!ObjectUtils.nullSafeEquals(country, address.country)) {
+			return false;
+		}
+		if (!ObjectUtils.nullSafeEquals(city, address.city)) {
+			return false;
+		}
+		if (!ObjectUtils.nullSafeEquals(streetName, address.streetName)) {
+			return false;
+		}
+		return ObjectUtils.nullSafeEquals(streetNo, address.streetNo);
+	}
+
+	@Override
+	public int hashCode() {
+		return ObjectUtils.nullSafeHash(country, city, streetName, streetNo);
+	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableUser.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableUser.java
index 65d4e6e2ad..59b561968f 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableUser.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableUser.java
@@ -24,7 +24,8 @@
 import java.util.Set;
 
 import org.springframework.data.jpa.domain.AbstractAuditable;
-import org.springframework.lang.Nullable;
+
+import org.jspecify.annotations.Nullable;
 
 /**
  * Sample auditable user to demonstrate working with {@code AbstractAuditableEntity}. No declaration of an ID is
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Role.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Role.java
index 101a784ee2..bdde7ce8f9 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Role.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Role.java
@@ -19,6 +19,8 @@
 import jakarta.persistence.GeneratedValue;
 import jakarta.persistence.Id;
 
+import org.springframework.util.ObjectUtils;
+
 /**
  * Sample domain class representing roles. Mapped with XML.
  *
@@ -55,4 +57,17 @@ public String toString() {
 	public boolean isNew() {
 		return id == null;
 	}
+
+	@Override
+	public boolean equals(Object o) {
+		if (!(o instanceof Role role)) {
+			return false;
+		}
+		return ObjectUtils.nullSafeEquals(id, role.id);
+	}
+
+	@Override
+	public int hashCode() {
+		return ObjectUtils.nullSafeHash(id);
+	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java
index fafd6fca4a..d4027be5ba 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java
@@ -60,6 +60,8 @@
 @NamedQueries({ //
 		@NamedQuery(name = "User.findByEmailAddress", //
 				query = "SELECT u FROM User u WHERE u.emailAddress = ?1"), //
+		@NamedQuery(name = "User.findByEmailAddress.count-provided", //
+				query = "SELECT count(u) FROM User u WHERE u.emailAddress = ?1"), //
 		@NamedQuery(name = "User.findByNamedQueryWithAliasInInvertedOrder", //
 				query = "SELECT u.lastname AS lastname, u.firstname AS firstname FROM User u ORDER BY u.lastname ASC"),
 		@NamedQuery(name = "User.findByNamedQueryWithConstructorExpression",
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java
index 304dcb5607..cbd8ffd410 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java
@@ -15,6 +15,7 @@
  */
 package org.springframework.data.jpa.domain.sample;
 
+import org.springframework.data.jpa.domain.PredicateSpecification;
 import org.springframework.data.jpa.domain.Specification;
 
 /**
@@ -25,24 +26,24 @@
  */
 public class UserSpecifications {
 
-	public static Specification<User> userHasFirstname(final String firstname) {
+	public static PredicateSpecification<User> userHasFirstname(final String firstname) {
 
 		return simplePropertySpec("firstname", firstname);
 	}
 
-	public static Specification<User> userHasLastname(final String lastname) {
+	public static PredicateSpecification<User> userHasLastname(final String lastname) {
 
 		return simplePropertySpec("lastname", lastname);
 	}
 
-	public static Specification<User> userHasFirstnameLike(final String expression) {
+	public static PredicateSpecification<User> userHasFirstnameLike(final String expression) {
 
-		return (root, query, cb) -> cb.like(root.get("firstname").as(String.class), String.format("%%%s%%", expression));
+		return (root, cb) -> cb.like(root.get("firstname").as(String.class), String.format("%%%s%%", expression));
 	}
 
-	public static Specification<User> userHasAgeLess(final Integer age) {
+	public static PredicateSpecification<User> userHasAgeLess(final Integer age) {
 
-		return (root, query, cb) -> cb.lessThan(root.get("age").as(Integer.class), age);
+		return (root, cb) -> cb.lessThan(root.get("age").as(Integer.class), age);
 	}
 
 	public static Specification<User> userHasLastnameLikeWithSort(final String expression) {
@@ -55,8 +56,8 @@ public static Specification<User> userHasLastnameLikeWithSort(final String expre
 		};
 	}
 
-	private static <T> Specification<T> simplePropertySpec(final String property, final Object value) {
+	private static <T> PredicateSpecification<T> simplePropertySpec(final String property, final Object value) {
 
-		return (root, query, builder) -> builder.equal(root.get(property), value);
+		return (root, builder) -> builder.equal(root.get(property), value);
 	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserWithOptionalField.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserWithOptionalField.java
index cdfb9a3bfc..2833123509 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserWithOptionalField.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserWithOptionalField.java
@@ -21,7 +21,7 @@
 
 import java.util.Optional;
 
-import org.springframework.lang.Nullable;
+import org.jspecify.annotations.Nullable;
 
 /**
  * @author Greg Turnquist
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/EclipseLinkMetamodelIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/EclipseLinkMetamodelIntegrationTests.java
index e3cf795046..d62094bbf8 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/EclipseLinkMetamodelIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/EclipseLinkMetamodelIntegrationTests.java
@@ -20,7 +20,7 @@
 import org.springframework.test.context.ContextConfiguration;
 
 /**
- * Metamodel tests using OpenJPA.
+ * Metamodel tests using Eclipselink.
  *
  * @author Oliver Gierke
  */
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/OpenJpaMetamodelIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/OpenJpaMetamodelIntegrationTests.java
deleted file mode 100644
index 16983f0f88..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/OpenJpaMetamodelIntegrationTests.java
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright 2013-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.springframework.data.jpa.infrastructure;
-
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-import org.springframework.test.context.ContextConfiguration;
-
-/**
- * Metamodel tests using OpenJPA.
- *
- * @author Oliver Gierke
- */
-@ContextConfiguration("classpath:openjpa.xml")
-class OpenJpaMetamodelIntegrationTests extends MetamodelIntegrationTests {
-
-	@Test
-	@Disabled
-	@Override
-	void canAccessParametersByIndexForNativeQueries() {}
-
-	/**
-	 * TODO: Remove once https://issues.apache.org/jira/browse/OPENJPA-2618 is fixed.
-	 */
-	@Test
-	@Disabled
-	@Override
-	void doesNotExposeAliasForTupleIfNoneDefined() {}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java
index 8593c1ed3e..31d4a44d42 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java
@@ -36,6 +36,10 @@ void executesNotInQueryCorrectly() {}
 	@Override
 	void executesInKeywordForPageCorrectly() {}
 
+	@Disabled
+	@Override
+	void shouldProjectWithKeysetScrolling() {}
+
 	@Disabled
 	@Override
 	void rawMapProjectionWithEntityAndAggregatedValue() {}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/JavaConfigUserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/JavaConfigUserRepositoryTests.java
index d87b9e152c..f40877701e 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/JavaConfigUserRepositoryTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/JavaConfigUserRepositoryTests.java
@@ -15,14 +15,15 @@
  */
 package org.springframework.data.jpa.repository;
 
-import java.io.IOException;
-import java.util.Collections;
-
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.PersistenceContext;
 
+import java.io.IOException;
+import java.util.Collections;
+
 import org.assertj.core.api.Assertions;
 import org.junit.jupiter.api.Test;
+
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.config.PropertiesFactoryBean;
@@ -42,8 +43,7 @@
 import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean;
 import org.springframework.data.repository.core.NamedQueries;
 import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries;
-import org.springframework.data.repository.query.ExtensionAwareQueryMethodEvaluationContextProvider;
-import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
+import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider;
 import org.springframework.data.spel.spi.EvaluationContextExtension;
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.support.AnnotationConfigContextLoader;
@@ -72,7 +72,7 @@ public EvaluationContextExtension sampleEvaluationContextExtension() {
 		@Bean
 		public UserRepository userRepository() throws Exception {
 
-			QueryMethodEvaluationContextProvider evaluationContextProvider = new ExtensionAwareQueryMethodEvaluationContextProvider(
+			ExtensionAwareEvaluationContextProvider evaluationContextProvider = new ExtensionAwareEvaluationContextProvider(
 					applicationContext);
 
 			JpaRepositoryFactoryBean<UserRepository, User, Integer> factory = new JpaRepositoryFactoryBean<>(
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaEntityGraphRepositoryMethodsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaEntityGraphRepositoryMethodsIntegrationTests.java
deleted file mode 100644
index c42ae99579..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaEntityGraphRepositoryMethodsIntegrationTests.java
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * Copyright 2014-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.springframework.data.jpa.repository;
-
-import org.springframework.test.context.ContextConfiguration;
-
-/**
- * @author Oliver Gierke
- */
-@ContextConfiguration("classpath:openjpa.xml")
-class OpenJpaEntityGraphRepositoryMethodsIntegrationTests extends EntityGraphRepositoryMethodsIntegrationTests {}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaNamespaceUserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaNamespaceUserRepositoryTests.java
deleted file mode 100644
index a69fb9e35c..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaNamespaceUserRepositoryTests.java
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * Copyright 2008-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.springframework.data.jpa.repository;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatThrownBy;
-
-import jakarta.persistence.EntityManager;
-import jakarta.persistence.PersistenceContext;
-import jakarta.persistence.TypedQuery;
-import jakarta.persistence.criteria.CriteriaBuilder;
-import jakarta.persistence.criteria.CriteriaQuery;
-import jakarta.persistence.criteria.ParameterExpression;
-import jakarta.persistence.criteria.Root;
-
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.List;
-
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-import org.springframework.data.jpa.domain.sample.User;
-import org.springframework.data.jpa.repository.sample.UserRepository;
-import org.springframework.test.context.ContextConfiguration;
-
-/**
- * Testcase to run {@link UserRepository} integration tests on top of OpenJPA.
- *
- * @author Oliver Gierke
- * @author Jens Schauder
- * @author Krzysztof Krason
- */
-@ContextConfiguration("classpath:openjpa.xml")
-class OpenJpaNamespaceUserRepositoryTests extends NamespaceUserRepositoryTests {
-
-	@PersistenceContext EntityManager em;
-
-	@Test
-	void checkQueryValidationWithOpenJpa() {
-
-		assertThatThrownBy(() -> em.createQuery("something absurd")).isInstanceOf(RuntimeException.class);
-		assertThatThrownBy(() -> em.createNamedQuery("not available")).isInstanceOf(RuntimeException.class);
-	}
-
-	/**
-	 * Test case for https://issues.apache.org/jira/browse/OPENJPA-2018
-	 */
-	@SuppressWarnings({ "rawtypes" })
-	@Test
-	@Disabled
-	void queryUsingIn() {
-
-		flushTestUsers();
-
-		CriteriaBuilder builder = em.getCriteriaBuilder();
-
-		CriteriaQuery<User> criteriaQuery = builder.createQuery(User.class);
-		Root<User> root = criteriaQuery.from(User.class);
-		ParameterExpression<Collection> parameter = builder.parameter(Collection.class);
-		criteriaQuery.where(root.<Integer> get("id").in(parameter));
-
-		TypedQuery<User> query = em.createQuery(criteriaQuery);
-		query.setParameter(parameter, Arrays.asList(1, 2));
-
-		List<User> resultList = query.getResultList();
-		assertThat(resultList).hasSize(2);
-	}
-
-	/**
-	 * Temporarily ignored until openjpa works with hsqldb 2.x.
-	 */
-	@Override
-	void shouldFindUsersInNativeQueryWithPagination() {}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaRepositoryWithCompositeKeyIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaRepositoryWithCompositeKeyIntegrationTests.java
deleted file mode 100644
index c6acc17b33..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaRepositoryWithCompositeKeyIntegrationTests.java
+++ /dev/null
@@ -1,33 +0,0 @@
-/*
- * Copyright 2016-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.springframework.data.jpa.repository;
-
-import org.springframework.context.annotation.ImportResource;
-import org.springframework.test.context.ContextConfiguration;
-
-/**
- * Testcase to run {@link RepositoryWithIdClassKeyTests} integration tests on top of OpenJPA.
- *
- * @author Mark Paluch
- */
-@ContextConfiguration
-class OpenJpaRepositoryWithCompositeKeyIntegrationTests extends RepositoryWithIdClassKeyTests {
-
-	@ImportResource({ "classpath:infrastructure.xml", "classpath:openjpa.xml" })
-	static class TestConfig extends Config {
-
-	}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaStoredProcedureIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaStoredProcedureIntegrationTests.java
deleted file mode 100644
index 6984b99e27..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaStoredProcedureIntegrationTests.java
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2015-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.springframework.data.jpa.repository;
-
-import org.junit.jupiter.api.Disabled;
-import org.springframework.context.annotation.ImportResource;
-import org.springframework.test.context.ContextConfiguration;
-
-/**
- * Test case to run {@link StoredProcedureIntegrationTests} integration tests on top of OpenJpa. This is currently not
- * supported since, the OpenJPA tests need to be executed with hsqldb1 which doesn't supported stored procedures.
- *
- * @author Thomas Darimont
- * @author Oliver Gierke
- */
-@Disabled
-@ContextConfiguration(classes = { StoredProcedureIntegrationTests.Config.class })
-class OpenJpaStoredProcedureIntegrationTests extends StoredProcedureIntegrationTests {
-
-	@ImportResource({ "classpath:infrastructure.xml", "classpath:openjpa.xml" })
-	static class TestConfig extends Config {}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SimpleJpaParameterBindingTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SimpleJpaParameterBindingTests.java
index efe754ad7b..bad8461741 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SimpleJpaParameterBindingTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SimpleJpaParameterBindingTests.java
@@ -15,7 +15,7 @@
  */
 package org.springframework.data.jpa.repository;
 
-import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.*;
 
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.PersistenceContext;
@@ -32,6 +32,7 @@
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+
 import org.springframework.data.jpa.domain.sample.User;
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
@@ -45,7 +46,6 @@
 @ExtendWith(SpringExtension.class)
 @ContextConfiguration({ "classpath:application-context.xml"
 // , "classpath:eclipselink.xml"
-// , "classpath:openjpa.xml"
 })
 @Transactional
 class SimpleJpaParameterBindingTests {
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java
index 4e2a545653..46721b1dfb 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java
@@ -15,10 +15,8 @@
  */
 package org.springframework.data.jpa.repository;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.springframework.data.domain.Sort.Direction.ASC;
-import static org.springframework.data.domain.Sort.Direction.DESC;
+import static org.assertj.core.api.Assertions.*;
+import static org.springframework.data.domain.Sort.Direction.*;
 
 import jakarta.persistence.EntityManager;
 
@@ -33,6 +31,7 @@
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.dao.InvalidDataAccessApiUsageException;
 import org.springframework.data.domain.Limit;
@@ -43,6 +42,7 @@
 import org.springframework.data.domain.Slice;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Window;
+import org.springframework.data.jpa.domain.sample.Address;
 import org.springframework.data.jpa.domain.sample.Role;
 import org.springframework.data.jpa.domain.sample.User;
 import org.springframework.data.jpa.provider.PersistenceProvider;
@@ -422,6 +422,11 @@ void dtoProjectionShouldApplyConstructorExpressionRewriting() {
 
 		assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) //
 				.contains("Dave", "Carter", "Oliver August");
+
+		dtos = userRepository.findRecordProjectionWithFunctions();
+
+		assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::lastname) //
+				.contains("matthews", "beauford");
 	}
 
 	@Test // GH-3076
@@ -442,6 +447,29 @@ void dynamicDtoProjection() {
 				.contains("Dave", "Carter", "Oliver August");
 	}
 
+	@Test // GH-3862
+	void shouldNotRewritePrimitiveSelectionToDtoProjection() {
+
+		oliver.setAge(28);
+		em.persist(oliver);
+
+		assertThat(userRepository.findAgeByAnnotatedQuery(oliver.getEmailAddress())).contains(28);
+	}
+
+	@Test // GH-3862
+	void shouldNotRewritePropertySelectionToDtoProjection() {
+
+		Address address = new Address("DE", "Dresden", "some street", "12345");
+		dave.setAddress(address);
+		userRepository.save(dave);
+		em.flush();
+		em.clear();
+
+		assertThat(userRepository.findAddressByAnnotatedQuery(dave.getEmailAddress())).contains(address);
+		assertThat(userRepository.findCityByAnnotatedQuery(dave.getEmailAddress())).contains("Dresden");
+		assertThat(userRepository.findRolesByAnnotatedQuery(dave.getEmailAddress())).contains(singer);
+	}
+
 	@Test // GH-3076
 	void dtoProjectionWithEntityAndAggregatedValue() {
 
@@ -495,6 +523,14 @@ void dtoProjectionWithEntityAndAggregatedValueWithPageable() {
 				});
 	}
 
+	@Test // GH-3857
+	void shouldApplyParameterNames() {
+
+		assertThat(userRepository.findAnnotatedWithParameterNameQuery(oliver.getLastname())).hasSize(2);
+		assertThat(userRepository.findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(oliver.getLastname(),
+				oliver.getLastname())).hasSize(2);
+	}
+
 	@ParameterizedTest // GH-3076
 	@ValueSource(classes = { UserRoleCountDtoProjection.class, UserRoleCountInterfaceProjection.class })
 	<T> void dynamicProjectionWithEntityAndAggregated(Class<T> resultType) {
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java
index ab620ee482..8980836d8d 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java
@@ -20,8 +20,6 @@
 import static org.springframework.data.domain.Example.*;
 import static org.springframework.data.domain.ExampleMatcher.*;
 import static org.springframework.data.domain.Sort.Direction.*;
-import static org.springframework.data.jpa.domain.Specification.*;
-import static org.springframework.data.jpa.domain.Specification.not;
 import static org.springframework.data.jpa.domain.sample.UserSpecifications.*;
 
 import jakarta.persistence.EntityManager;
@@ -48,8 +46,8 @@
 
 import org.assertj.core.api.SoftAssertions;
 import org.hibernate.LazyInitializationException;
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 
@@ -62,7 +60,11 @@
 import org.springframework.data.domain.*;
 import org.springframework.data.domain.Sort.Direction;
 import org.springframework.data.domain.Sort.Order;
+import org.springframework.data.jpa.domain.DeleteSpecification;
+import org.springframework.data.jpa.domain.JpaSort;
+import org.springframework.data.jpa.domain.PredicateSpecification;
 import org.springframework.data.jpa.domain.Specification;
+import org.springframework.data.jpa.domain.UpdateSpecification;
 import org.springframework.data.jpa.domain.sample.Address;
 import org.springframework.data.jpa.domain.sample.QUser;
 import org.springframework.data.jpa.domain.sample.Role;
@@ -73,7 +75,6 @@
 import org.springframework.data.jpa.repository.sample.UserRepository;
 import org.springframework.data.jpa.repository.sample.UserRepository.NameOnly;
 import org.springframework.data.jpa.util.DisabledOnHibernate;
-import org.springframework.lang.Nullable;
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 import org.springframework.transaction.annotation.Transactional;
@@ -455,7 +456,6 @@ void testOverwritingFinder() {
 
 	@Test
 	void testUsesQueryAnnotation() {
-
 		assertThat(repository.findByAnnotatedQuery("gierke@synyx.de")).isNull();
 	}
 
@@ -470,7 +470,7 @@ void testExecutionOfProjectingMethod() {
 	void executesSpecificationCorrectly() {
 
 		flushTestUsers();
-		assertThat(repository.findAll(where(userHasFirstname("Oliver")))).hasSize(1);
+		assertThat(repository.findAll(Specification.where(userHasFirstname("Oliver")))).hasSize(1);
 	}
 
 	@Test
@@ -500,11 +500,11 @@ void throwsExceptionForUnderSpecifiedSingleEntitySpecification() {
 	void executesCombinedSpecificationsCorrectly() {
 
 		flushTestUsers();
-		Specification<User> spec1 = userHasFirstname("Oliver").or(userHasLastname("Arrasz"));
+		PredicateSpecification<User> spec1 = userHasFirstname("Oliver").or(userHasLastname("Arrasz"));
 		List<User> users1 = repository.findAll(spec1);
 		assertThat(users1).hasSize(2);
 
-		Specification<User> spec2 = Specification.anyOf( //
+		PredicateSpecification<User> spec2 = PredicateSpecification.anyOf( //
 				userHasFirstname("Oliver"), //
 				userHasLastname("Arrasz"));
 		List<User> users2 = repository.findAll(spec2);
@@ -517,7 +517,8 @@ void executesCombinedSpecificationsCorrectly() {
 	void executesNegatingSpecificationCorrectly() {
 
 		flushTestUsers();
-		Specification<User> spec = not(userHasFirstname("Oliver")).and(userHasLastname("Arrasz"));
+		PredicateSpecification<User> spec = PredicateSpecification.not(userHasFirstname("Oliver"))
+				.and(userHasLastname("Arrasz"));
 
 		assertThat(repository.findAll(spec)).containsOnly(secondUser);
 	}
@@ -526,18 +527,18 @@ void executesNegatingSpecificationCorrectly() {
 	void executesCombinedSpecificationsWithPageableCorrectly() {
 
 		flushTestUsers();
-		Specification<User> spec1 = userHasFirstname("Oliver").or(userHasLastname("Arrasz"));
+		PredicateSpecification<User> spec1 = userHasFirstname("Oliver").or(userHasLastname("Arrasz"));
 
-		Page<User> users1 = repository.findAll(spec1, PageRequest.of(0, 1));
+		Page<User> users1 = repository.findAll(Specification.where(spec1), PageRequest.of(0, 1));
 		assertThat(users1.getSize()).isOne();
 		assertThat(users1.hasPrevious()).isFalse();
 		assertThat(users1.getTotalElements()).isEqualTo(2L);
 
-		Specification<User> spec2 = Specification.anyOf( //
+		PredicateSpecification<User> spec2 = PredicateSpecification.anyOf( //
 				userHasFirstname("Oliver"), //
 				userHasLastname("Arrasz"));
 
-		Page<User> users2 = repository.findAll(spec2, PageRequest.of(0, 1));
+		Page<User> users2 = repository.findAll(Specification.where(spec2), PageRequest.of(0, 1));
 		assertThat(users2.getSize()).isOne();
 		assertThat(users2.hasPrevious()).isFalse();
 		assertThat(users2.getTotalElements()).isEqualTo(2L);
@@ -592,7 +593,7 @@ void executesSimpleNotCorrectly() {
 	void returnsSameListIfNoSpecGiven() {
 
 		flushTestUsers();
-		assertSameElements(repository.findAll(), repository.findAll((Specification<User>) null));
+		assertSameElements(repository.findAll(), repository.findAll(PredicateSpecification.unrestricted()));
 	}
 
 	@Test
@@ -608,15 +609,41 @@ void returnsSamePageIfNoSpecGiven() {
 		Pageable pageable = PageRequest.of(0, 1);
 
 		flushTestUsers();
-		assertThat(repository.findAll((Specification<User>) null, pageable)).isEqualTo(repository.findAll(pageable));
+		assertThat(repository.findAll(Specification.unrestricted(), pageable)).isEqualTo(repository.findAll(pageable));
+	}
+
+	@Test // GH-3521
+	void updateSpecificationUpdatesMarriedEntities() {
+
+		flushTestUsers();
+
+		UpdateSpecification<User> updateLastname = UpdateSpecification.<User> update((root, update, criteriaBuilder) -> {
+			update.set("lastname", "Drotbohm");
+		}).where(userHasFirstname("Oliver").and(userHasLastname("Gierke")));
+
+		long updated = repository.update(updateLastname);
+
+		assertThat(updated).isOne();
+		assertThat(repository.count(userHasFirstname("Oliver").and(userHasLastname("Gierke")))).isZero();
+		assertThat(repository.count(userHasFirstname("Oliver").and(userHasLastname("Drotbohm")))).isOne();
 	}
 
 	@Test // GH-2796
-	void removesAllIfSpecificationIsNull() {
+	void predicateSpecificationRemovesAll() {
 
 		flushTestUsers();
 
-		repository.delete((Specification<User>) null);
+		repository.delete(DeleteSpecification.unrestricted());
+
+		assertThat(repository.count()).isEqualTo(0L);
+	}
+
+	@Test // GH-2796
+	void deleteSpecificationRemovesAll() {
+
+		flushTestUsers();
+
+		repository.delete(DeleteSpecification.unrestricted());
 
 		assertThat(repository.count()).isEqualTo(0L);
 	}
@@ -776,9 +803,6 @@ void executesFinderWithFalseKeywordCorrectly() {
 		assertThat(repository.findByActiveFalse()).containsOnly(firstUser);
 	}
 
-	/**
-	 * Ignored until the query declaration is supported by OpenJPA.
-	 */
 	@Test
 	void executesAnnotatedCollectionMethodCorrectly() {
 
@@ -1591,11 +1615,7 @@ void deleteByShouldReturnEmptyListInCaseNoEntityHasBeenRemovedAndReturnTypeIsCol
 		assertThat(repository.deleteByLastname("dorfuaeB")).isEmpty();
 	}
 
-	/**
-	 * @see <a href="https://issues.apache.org/jira/browse/OPENJPA-2484">OPENJPA-2484</a>
-	 */
 	@Test // DATAJPA-505
-	@Disabled
 	void findBinaryDataByIdJpaQl() throws Exception {
 
 		byte[] data = "Woho!!".getBytes("UTF-8");
@@ -2839,6 +2859,17 @@ void findByFluentSpecificationWithSimplePropertyPathsDoesntLoadUnrequestedPaths(
 				);
 	}
 
+	@Test // GH-3877
+	void delete() {
+
+		flushTestUsers();
+		em.clear();
+
+		long delete = repository.delete(QUser.user.firstname.eq(firstUser.getFirstname()));
+
+		assertThat(delete).isEqualTo(1);
+	}
+
 	@Test // GH-2820
 	void findByFluentPredicateWithProjectionAndPageRequest() {
 
@@ -3170,6 +3201,38 @@ void handlesColonsFollowedByIntegerInStringLiteral() {
 		assertThat(users).extracting(User::getId).containsExactly(expected.getId());
 	}
 
+	@Test // GH-3172
+	void specificationShouldApplyUnsafeSort() {
+
+		flushTestUsers();
+		firstUser.setManager(firstUser);
+		secondUser.setManager(firstUser);
+		thirdUser.setManager(secondUser);
+		fourthUser.setManager(secondUser);
+		repository.saveAllAndFlush(List.of(firstUser, secondUser, thirdUser, fourthUser));
+
+		PredicateSpecification<User> spec = userHasFirstname("Oliver").or(userHasLastname("Matthews"));
+
+		List<User> result = repository.findBy(spec, q -> q.sortBy(JpaSort.unsafe("LENGTH(firstname)")).all());
+
+		assertThat(result).containsExactly(thirdUser, firstUser);
+	}
+
+	@Test // GH-3172
+	void findAllShouldApplyUnsafeSort() {
+
+		flushTestUsers();
+		firstUser.setManager(firstUser);
+		secondUser.setManager(firstUser);
+		thirdUser.setManager(secondUser);
+		fourthUser.setManager(secondUser);
+		repository.saveAllAndFlush(List.of(firstUser, secondUser, thirdUser, fourthUser));
+
+		assertThat(
+				repository.findAll(JpaSort.unsafe("case when firstname ilike 'O%' escape '^' then 'A' else firstname end")))
+				.containsExactly(firstUser, thirdUser, secondUser, fourthUser);
+	}
+
 	@Test // DATAJPA-1233, GH-3756
 	void handlesCountQueriesWithLessParametersSingleParam() {
 
@@ -3319,6 +3382,15 @@ void findByElementCollectionInAttributeIgnoreCase() {
 
 		flushTestUsers();
 
+		/*
+		TODO: Hibernate-generated HQL for the CriteriaBuilder-based API. Yields only one result in contrast to the CriteriaBuilder one.
+		Query query = em.createQuery("select alias_544097980 from org.springframework.data.jpa.domain.sample.User alias_544097980 left join alias_544097980.attributes alias_975381534 where alias_975381534 in (?1)")
+				.setParameter(1, asList("cOOl", "hIP"));
+
+		List resultList = query.getResultList();
+
+		*/
+
 		List<User> result = repository.findByAttributesIgnoreCaseIn(new HashSet<>(asList("cOOl", "hIP")));
 
 		assertThat(result).containsOnly(firstUser, secondUser);
@@ -3387,8 +3459,8 @@ void existsWithSpec() {
 
 		flushTestUsers();
 
-		Specification<User> minorSpec = userHasAgeLess(18);
-		Specification<User> hundredYearsOld = userHasAgeLess(100);
+		PredicateSpecification<User> minorSpec = userHasAgeLess(18);
+		PredicateSpecification<User> hundredYearsOld = userHasAgeLess(100);
 
 		assertThat(repository.exists(minorSpec)).isFalse();
 		assertThat(repository.exists(hundredYearsOld)).isTrue();
@@ -3413,7 +3485,7 @@ void deleteWithSpec() {
 
 		flushTestUsers();
 
-		Specification<User> usersWithEInTheirName = userHasFirstnameLike("e");
+		PredicateSpecification<User> usersWithEInTheirName = userHasFirstnameLike("e");
 
 		long initialCount = repository.count();
 		assertThat(repository.delete(usersWithEInTheirName)).isEqualTo(3L);
@@ -3560,16 +3632,16 @@ private Page<User> executeSpecWithSort(Sort sort) {
 
 		flushTestUsers();
 
-		Specification<User> spec1 = userHasFirstname("Oliver").or(userHasLastname("Matthews"));
+		PredicateSpecification<User> spec1 = userHasFirstname("Oliver").or(userHasLastname("Matthews"));
 
-		Page<User> result1 = repository.findAll(spec1, PageRequest.of(0, 1, sort));
+		Page<User> result1 = repository.findAll(Specification.where(spec1), PageRequest.of(0, 1, sort));
 		assertThat(result1.getTotalElements()).isEqualTo(2L);
 
-		Specification<User> spec2 = Specification.anyOf( //
+		PredicateSpecification<User> spec2 = PredicateSpecification.anyOf( //
 				userHasFirstname("Oliver"), //
 				userHasLastname("Matthews"));
 
-		Page<User> result2 = repository.findAll(spec2, PageRequest.of(0, 1, sort));
+		Page<User> result2 = repository.findAll(Specification.where(spec2), PageRequest.of(0, 1, sort));
 		assertThat(result2.getTotalElements()).isEqualTo(2L);
 
 		assertThat(result1).containsExactlyElementsOf(result2);
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/AotContributionIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/AotContributionIntegrationTests.java
new file mode 100644
index 0000000000..7f9cd170ec
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/AotContributionIntegrationTests.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.*;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.aot.generate.GeneratedFiles;
+import org.springframework.aot.test.generate.TestGenerationContext;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.FilterType;
+import org.springframework.context.aot.ApplicationContextAotGenerator;
+import org.springframework.core.io.InputStreamResource;
+import org.springframework.core.io.InputStreamSource;
+import org.springframework.data.aot.AotContext;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.data.jpa.repository.config.InfrastructureConfig;
+import org.springframework.mock.env.MockPropertySource;
+
+/**
+ * Integration tests for AOT processing.
+ *
+ * @author Mark Paluch
+ */
+class AotContributionIntegrationTests {
+
+	@EnableJpaRepositories(considerNestedRepositories = true, includeFilters = {
+			@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, value = QuerydslUserRepository.class) })
+	static class AotConfiguration extends InfrastructureConfig {
+
+	}
+
+	@Test // GH-3830
+	void shouldGenerateMetadataForBaseRepositoryAndQuerydslFragment() throws IOException {
+
+		TestGenerationContext generationContext = generate(AotConfiguration.class);
+
+		InputStreamSource metadata = generationContext.getGeneratedFiles().getGeneratedFile(GeneratedFiles.Kind.RESOURCE,
+				QuerydslUserRepository.class.getName().replace('.', '/') + ".json");
+
+		InputStreamResource isr = new InputStreamResource(metadata);
+		String json = isr.getContentAsString(StandardCharsets.UTF_8);
+
+		assertThatJson(json).inPath("$.methods[?(@.name == 'findBy')].fragment").isArray().first().isObject()
+				.containsEntry("interface", "org.springframework.data.jpa.repository.support.QuerydslJpaPredicateExecutor")
+				.containsEntry("fragment", "org.springframework.data.jpa.repository.support.QuerydslJpaPredicateExecutor");
+
+		assertThatJson(json).inPath("$.methods[?(@.name == 'existsById')].fragment").isArray().first().isObject()
+				.containsEntry("fragment", "org.springframework.data.jpa.repository.support.SimpleJpaRepository");
+	}
+
+	private static TestGenerationContext generate(Class<?>... configurationClasses) {
+
+		AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
+		context.getEnvironment().getPropertySources()
+				.addFirst(new MockPropertySource().withProperty(AotContext.GENERATED_REPOSITORIES_ENABLED, "true"));
+		context.register(configurationClasses);
+
+		ApplicationContextAotGenerator generator = new ApplicationContextAotGenerator();
+
+		TestGenerationContext generationContext = new TestGenerationContext();
+		generator.processAheadOfTime(context, generationContext);
+		return generationContext;
+	}
+
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/AotFragmentTestConfigurationSupport.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/AotFragmentTestConfigurationSupport.java
new file mode 100644
index 0000000000..b73f9cc0d8
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/AotFragmentTestConfigurationSupport.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+
+import org.springframework.aot.test.generate.TestGenerationContext;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.AbstractBeanDefinition;
+import org.springframework.beans.factory.support.BeanDefinitionBuilder;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ImportResource;
+import org.springframework.core.test.tools.TestCompiler;
+import org.springframework.data.projection.ProjectionFactory;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport;
+import org.springframework.data.repository.query.ValueExpressionDelegate;
+import org.springframework.orm.jpa.SharedEntityManagerCreator;
+import org.springframework.util.ReflectionUtils;
+
+/**
+ * Test Configuration Support Class for generated AOT Repository Fragments based on a Repository Interface.
+ * <p>
+ * This configuration generates the AOT repository, compiles sources and configures a BeanFactory to contain the AOT
+ * fragment. Additionally, the fragment is exposed through a {@code repositoryInterface} JDK proxy forwarding method
+ * invocations to the backing AOT fragment. Note that {@code repositoryInterface} is not a repository proxy.
+ *
+ * @author Mark Paluch
+ */
+@ImportResource("classpath:/infrastructure.xml")
+class AotFragmentTestConfigurationSupport implements BeanFactoryPostProcessor {
+
+	private final Class<?> repositoryInterface;
+	private final TestJpaAotRepositoryContext<?> repositoryContext;
+
+	public AotFragmentTestConfigurationSupport(Class<?> repositoryInterface) {
+		this.repositoryInterface = repositoryInterface;
+		this.repositoryContext = new TestJpaAotRepositoryContext<>(repositoryInterface, null);
+	}
+
+	@Override
+	public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
+
+		TestGenerationContext generationContext = new TestGenerationContext(repositoryInterface);
+
+		new JpaRepositoryContributor(repositoryContext).contribute(generationContext);
+
+		AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder
+				.genericBeanDefinition(repositoryInterface.getName() + "Impl__Aot")
+				.addConstructorArgReference("jpaSharedEM_entityManagerFactory")
+				.addConstructorArgValue(getCreationContext(repositoryContext)).getBeanDefinition();
+
+		TestCompiler.forSystem().withCompilerOptions("-parameters").with(generationContext).compile(compiled -> {
+			beanFactory.setBeanClassLoader(compiled.getClassLoader());
+			((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragment", aotGeneratedRepository);
+		});
+
+		BeanDefinition fragmentFacade = BeanDefinitionBuilder.rootBeanDefinition((Class) repositoryInterface, () -> {
+
+			Object fragment = beanFactory.getBean("fragment");
+			Object proxy = getFragmentFacadeProxy(fragment);
+
+			return repositoryInterface.cast(proxy);
+		}).getBeanDefinition();
+
+		((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragmentFacade", fragmentFacade);
+	}
+
+	private Object getFragmentFacadeProxy(Object fragment) {
+
+		return Proxy.newProxyInstance(repositoryInterface.getClassLoader(), new Class<?>[] { repositoryInterface },
+				(p, method, args) -> {
+
+					Method target = ReflectionUtils.findMethod(fragment.getClass(), method.getName(), method.getParameterTypes());
+
+					if (target == null) {
+						throw new NoSuchMethodException("Method [%s] is not implemented by [%s]".formatted(method, target));
+					}
+
+					try {
+						return target.invoke(fragment, args);
+					} catch (ReflectiveOperationException e) {
+						ReflectionUtils.handleReflectionException(e);
+					}
+
+					return null;
+				});
+	}
+
+	@Bean("jpaSharedEM_entityManagerFactory")
+	EntityManager sharedEntityManagerCreator(EntityManagerFactory emf) {
+		return SharedEntityManagerCreator.createSharedEntityManager(emf);
+	}
+
+	private RepositoryFactoryBeanSupport.FragmentCreationContext getCreationContext(
+			TestJpaAotRepositoryContext<?> repositoryContext) {
+
+		RepositoryFactoryBeanSupport.FragmentCreationContext creationContext = new RepositoryFactoryBeanSupport.FragmentCreationContext() {
+			@Override
+			public RepositoryMetadata getRepositoryMetadata() {
+				return repositoryContext.getRepositoryInformation();
+			}
+
+			@Override
+			public ValueExpressionDelegate getValueExpressionDelegate() {
+				return ValueExpressionDelegate.create();
+			}
+
+			@Override
+			public ProjectionFactory getProjectionFactory() {
+				return new SpelAwareProxyProjectionFactory();
+			}
+		};
+
+		return creationContext;
+	}
+
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java
new file mode 100644
index 0000000000..0d649778ee
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorIntegrationTests.java
@@ -0,0 +1,654 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import static org.assertj.core.api.Assertions.*;
+
+import jakarta.persistence.EntityManager;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import org.hibernate.proxy.HibernateProxy;
+import org.hibernate.query.QueryTypeMismatchException;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.domain.Limit;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.sample.Role;
+import org.springframework.data.jpa.domain.sample.SpecialUser;
+import org.springframework.data.jpa.domain.sample.User;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Integration tests for the {@link UserRepository} AOT fragment.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ */
+@SpringJUnitConfig(classes = JpaRepositoryContributorIntegrationTests.JpaRepositoryContributorConfiguration.class)
+@Transactional
+class JpaRepositoryContributorIntegrationTests {
+
+	@Autowired UserRepository fragment;
+	@Autowired EntityManager em;
+	User luke, leia, han, chewbacca, yoda, vader, kylo;
+	Role smuggler, jedi, imperium;
+
+	@Configuration
+	static class JpaRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport {
+		public JpaRepositoryContributorConfiguration() {
+			super(UserRepository.class);
+		}
+	}
+
+	@BeforeEach
+	void beforeEach() {
+
+		em.createQuery("DELETE FROM %s".formatted(User.class.getName())).executeUpdate();
+		em.createQuery("DELETE FROM %s".formatted(Role.class.getName())).executeUpdate();
+
+		smuggler = em.merge(new Role("Smuggler"));
+		jedi = em.merge(new Role("Jedi"));
+		imperium = em.merge(new Role("Imperium"));
+
+		luke = new User("Luke", "Skywalker", "luke@jedi.org");
+		luke.addRole(jedi);
+		em.persist(luke);
+
+		leia = new User("Leia", "Organa", "leia@resistance.gov");
+		em.persist(leia);
+
+		han = new User("Han", "Solo", "han@smuggler.net");
+		han.setManager(luke);
+		em.persist(han);
+
+		chewbacca = new User("Chewbacca", "n/a", "chewie@smuggler.net");
+		chewbacca.setManager(han);
+		chewbacca.addRole(smuggler);
+		em.persist(chewbacca);
+
+		yoda = new User("Yoda", "n/a", "yoda@jedi.org");
+		em.persist(yoda);
+
+		vader = new User("Anakin", "Skywalker", "vader@empire.com");
+		em.persist(vader);
+
+		kylo = new User("Ben", "Solo", "kylo@new-empire.com");
+		em.persist(kylo);
+
+		em.flush();
+		em.clear();
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderWithoutArguments() {
+
+		List<User> users = fragment.findUserNoArgumentsBy();
+		assertThat(users).hasSize(7).hasOnlyElementsOfType(User.class);
+	}
+
+	@Test // GH-3830
+	void testFindDerivedQuerySingleEntity() {
+
+		User user = fragment.findOneByEmailAddress("luke@jedi.org");
+		assertThat(user.getLastname()).isEqualTo("Skywalker");
+	}
+
+	@Test // GH-3830
+	void testFindDerivedFinderOptionalEntity() {
+
+		Optional<User> user = fragment.findOptionalOneByEmailAddress("yoda@jedi.org");
+		assertThat(user).isNotNull().containsInstanceOf(User.class)
+				.hasValueSatisfying(it -> assertThat(it).extracting(User::getFirstname).isEqualTo("Yoda"));
+	}
+
+	@Test // GH-3830
+	void testDerivedCount() {
+
+		Long value = fragment.countUsersByLastname("Skywalker");
+		assertThat(value).isEqualTo(2L);
+	}
+
+	@Test // GH-3830
+	void testDerivedExists() {
+
+		Boolean exists = fragment.existsUserByLastname("Skywalker");
+		assertThat(exists).isTrue();
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderReturningList() {
+
+		List<User> users = fragment.findByLastnameStartingWith("S");
+		assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("luke@jedi.org", "vader@empire.com",
+				"kylo@new-empire.com", "han@smuggler.net");
+	}
+
+	@Test // GH-3830
+	void shouldReturnStream() {
+
+		Stream<User> users = fragment.streamByLastnameLike("S%");
+		assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("luke@jedi.org", "vader@empire.com",
+				"kylo@new-empire.com", "han@smuggler.net");
+	}
+
+	@Test // GH-3830
+	void testLimitedDerivedFinder() {
+
+		List<User> users = fragment.findTop2ByLastnameStartingWith("S");
+		assertThat(users).hasSize(2);
+	}
+
+	@Test // GH-3830
+	void testSortedDerivedFinder() {
+
+		List<User> users = fragment.findByLastnameStartingWithOrderByEmailAddress("S");
+		assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com",
+				"luke@jedi.org", "vader@empire.com");
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderWithLimitArgument() {
+
+		List<User> users = fragment.findByLastnameStartingWith("S", Limit.of(2));
+		assertThat(users).hasSize(2);
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderWithSort() {
+
+		List<User> users = fragment.findByLastnameStartingWith("S", Sort.by("emailAddress"));
+		assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com",
+				"luke@jedi.org", "vader@empire.com");
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderWithSortAndLimit() {
+
+		List<User> users = fragment.findByLastnameStartingWith("S", Sort.by("emailAddress"), Limit.of(2));
+		assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com");
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderReturningListWithPageable() {
+
+		List<User> users = fragment.findByLastnameStartingWith("S", PageRequest.of(0, 2, Sort.by("emailAddress")));
+		assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com");
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderReturningPage() {
+
+		Page<User> page = fragment.findPageOfUsersByLastnameStartingWith("S",
+				PageRequest.of(0, 2, Sort.by("emailAddress")));
+
+		assertThat(page.getTotalElements()).isEqualTo(4);
+		assertThat(page.getSize()).isEqualTo(2);
+		assertThat(page.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net",
+				"kylo@new-empire.com");
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderReturningSlice() {
+
+		Slice<User> slice = fragment.findSliceOfUserByLastnameStartingWith("S",
+				PageRequest.of(0, 2, Sort.by("emailAddress")));
+
+		assertThat(slice.hasNext()).isTrue();
+		assertThat(slice.getSize()).isEqualTo(2);
+		assertThat(slice.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net",
+				"kylo@new-empire.com");
+	}
+
+	@Test // GH-3830
+	void testAnnotatedFinderReturningSingleValueWithQuery() {
+
+		User user = fragment.findAnnotatedQueryByEmailAddress("yoda@jedi.org");
+		assertThat(user).isNotNull().extracting(User::getFirstname).isEqualTo("Yoda");
+	}
+
+	@Test // GH-3830
+	void testAnnotatedFinderReturningListWithQuery() {
+
+		List<User> users = fragment.findAnnotatedQueryByLastname("S");
+		assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net",
+				"kylo@new-empire.com", "luke@jedi.org", "vader@empire.com");
+	}
+
+	@Test // GH-3830
+	void testAnnotatedFinderUsingNamedParameterPlaceholderReturningListWithQuery() {
+
+		List<User> users = fragment.findAnnotatedQueryByLastnameParameter("S");
+		assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net",
+				"kylo@new-empire.com", "luke@jedi.org", "vader@empire.com");
+	}
+
+	@Test // GH-3830
+	void shouldApplyAnnotatedLikeStartsEnds() {
+
+		// start with case
+		List<User> users = fragment.findAnnotatedLikeStartsEnds("S");
+		assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net",
+				"kylo@new-empire.com", "luke@jedi.org", "vader@empire.com");
+
+		// ends case
+		users = fragment.findAnnotatedLikeStartsEnds("a");
+		assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("leia@resistance.gov",
+				"chewie@smuggler.net", "yoda@jedi.org");
+	}
+
+	@Test // GH-3830
+	void testAnnotatedMultilineFinderWithQuery() {
+
+		List<User> users = fragment.findAnnotatedMultilineQueryByLastname("S");
+		assertThat(users).extracting(User::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net",
+				"kylo@new-empire.com", "luke@jedi.org", "vader@empire.com");
+	}
+
+	@Test // GH-3830
+	void testAnnotatedFinderWithQueryAndLimit() {
+
+		List<User> users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2));
+		assertThat(users).hasSize(2);
+	}
+
+	@Test // GH-3830
+	void testAnnotatedFinderWithQueryAndSort() {
+
+		List<User> users = fragment.findAnnotatedQueryByLastname("S", Sort.by("emailAddress"));
+		assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com",
+				"luke@jedi.org", "vader@empire.com");
+	}
+
+	@Test // GH-3830
+	void testAnnotatedFinderWithQueryLimitAndSort() {
+
+		List<User> users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2), Sort.by("emailAddress"));
+		assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com");
+	}
+
+	@Test // GH-3830
+	void testAnnotatedFinderReturningListWithPageable() {
+
+		List<User> users = fragment.findAnnotatedQueryByLastname("S", PageRequest.of(0, 2, Sort.by("emailAddress")));
+		assertThat(users).extracting(User::getEmailAddress).containsExactly("han@smuggler.net", "kylo@new-empire.com");
+	}
+
+	@Test // GH-3830
+	void testAnnotatedFinderReturningPage() {
+
+		Page<User> page = fragment.findAnnotatedQueryPageOfUsersByLastname("S",
+				PageRequest.of(0, 2, Sort.by("emailAddress")));
+
+		assertThat(page.getTotalElements()).isEqualTo(4);
+		assertThat(page.getSize()).isEqualTo(2);
+		assertThat(page.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net",
+				"kylo@new-empire.com");
+	}
+
+	@Test // GH-3830
+	void testPagingAnnotatedQueryWithSort() {
+
+		Page<User> page = fragment.findAnnotatedQueryPageWithStaticSort("S", PageRequest.of(0, 2, Sort.unsorted()));
+
+		assertThat(page.getTotalElements()).isEqualTo(4);
+		assertThat(page.getSize()).isEqualTo(2);
+		assertThat(page.getContent()).extracting(User::getEmailAddress).containsExactly("luke@jedi.org",
+				"vader@empire.com");
+	}
+
+	@Test // GH-3857
+	void appliesCustomParameterNaming() {
+
+		assertThat(fragment.findAnnotatedWithParameterNameQuery("S")).hasSize(4);
+		assertThat(fragment.findWithParameterNameByLastnameStartingWithOrLastnameEndingWith("S", "S")).hasSize(4);
+	}
+
+	@Test // GH-3830
+	void testAnnotatedFinderReturningSlice() {
+
+		Slice<User> slice = fragment.findAnnotatedQuerySliceOfUsersByLastname("S",
+				PageRequest.of(0, 2, Sort.by("emailAddress")));
+		assertThat(slice.hasNext()).isTrue();
+		assertThat(slice.getSize()).isEqualTo(2);
+		assertThat(slice.getContent()).extracting(User::getEmailAddress).containsExactly("han@smuggler.net",
+				"kylo@new-empire.com");
+	}
+
+	@Test // GH-3830
+	void shouldResolveTemplatedQuery() {
+
+		User user = fragment.findTemplatedByEmailAddress("han@smuggler.net");
+
+		assertThat(user).isNotNull();
+		assertThat(user.getFirstname()).isEqualTo("Han");
+	}
+
+	@Test // GH-3830
+	void shouldEvaluateExpressionByName() {
+
+		User user = fragment.findValueExpressionNamedByEmailAddress("han@smuggler.net");
+
+		assertThat(user).isNotNull();
+		assertThat(user.getFirstname()).isEqualTo("Han");
+	}
+
+	@Test // GH-3830
+	void shouldEvaluateExpressionByPosition() {
+
+		User user = fragment.findValueExpressionPositionalByEmailAddress("han@smuggler.net");
+
+		assertThat(user).isNotNull();
+		assertThat(user.getFirstname()).isEqualTo("Han");
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderReturningListOfProjections() {
+
+		List<UserDtoProjection> users = fragment.findUserProjectionByLastnameStartingWith("S");
+		assertThat(users).extracting(UserDtoProjection::getEmailAddress).containsExactlyInAnyOrder("han@smuggler.net",
+				"kylo@new-empire.com", "luke@jedi.org", "vader@empire.com");
+	}
+
+	@Test // GH-3830
+	void testDerivedFinderReturningPageOfProjections() {
+
+		Page<UserDtoProjection> page = fragment.findUserProjectionByLastnameStartingWith("S",
+				PageRequest.of(0, 2, Sort.by("emailAddress")));
+
+		assertThat(page.getTotalElements()).isEqualTo(4);
+		assertThat(page.getSize()).isEqualTo(2);
+		assertThat(page.getContent()).extracting(UserDtoProjection::getEmailAddress).containsExactly("han@smuggler.net",
+				"kylo@new-empire.com");
+
+		Page<UserDtoProjection> noResults = fragment.findUserProjectionByLastnameStartingWith("a",
+				PageRequest.of(0, 2, Sort.by("emailAddress")));
+
+		assertThat(noResults).isEmpty();
+	}
+
+	@Test // GH-3830
+	void shouldApplySqlResultSetMapping() {
+
+		User.EmailDto result = fragment.findEmailDtoByNativeQuery(kylo.getId());
+
+		assertThat(result.getOne()).isEqualTo(kylo.getEmailAddress());
+	}
+
+	@Test // GH-3830
+	void shouldApplyNamedDto() {
+
+		// named queries cannot be rewritten
+		assertThatExceptionOfType(QueryTypeMismatchException.class)
+				.isThrownBy(() -> fragment.findNamedDtoEmailAddress(kylo.getEmailAddress()));
+	}
+
+	@Test // GH-3830
+	void shouldApplyDerivedDto() {
+
+		UserRepository.Names names = fragment.findDtoByEmailAddress(kylo.getEmailAddress());
+
+		assertThat(names.lastname()).isEqualTo(kylo.getLastname());
+		assertThat(names.firstname()).isEqualTo(kylo.getFirstname());
+	}
+
+	@Test // GH-3830
+	void shouldApplyDerivedDtoPage() {
+
+		Page<UserRepository.Names> names = fragment.findDtoPageByEmailAddress(kylo.getEmailAddress(), PageRequest.of(0, 1));
+
+		assertThat(names).hasSize(1);
+		assertThat(names.getContent().get(0).lastname()).isEqualTo(kylo.getLastname());
+	}
+
+	@Test // GH-3830
+	void shouldApplyAnnotatedDto() {
+
+		UserRepository.Names names = fragment.findAnnotatedDtoEmailAddress(kylo.getEmailAddress());
+
+		assertThat(names.lastname()).isEqualTo(kylo.getLastname());
+		assertThat(names.firstname()).isEqualTo(kylo.getFirstname());
+	}
+
+	@Test // GH-3830
+	void shouldApplyAnnotatedDtoPage() {
+
+		Page<UserRepository.Names> names = fragment.findAnnotatedDtoPageByEmailAddress(kylo.getEmailAddress(),
+				PageRequest.of(0, 1));
+
+		assertThat(names).hasSize(1);
+		assertThat(names.getContent().get(0).lastname()).isEqualTo(kylo.getLastname());
+	}
+
+	@Test // GH-3830
+	void shouldApplyDerivedQueryInterfaceProjection() {
+
+		UserRepository.EmailOnly result = fragment.findEmailProjectionById(kylo.getId());
+
+		assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress());
+	}
+
+	@Test // GH-3830
+	void shouldApplyInterfaceProjectionPage() {
+
+		Page<UserRepository.EmailOnly> result = fragment.findProjectedPageByEmailAddress(kylo.getEmailAddress(),
+				PageRequest.of(0, 1));
+
+		assertThat(result).hasSize(1);
+		assertThat(result.getContent().get(0).getEmailAddress()).isEqualTo(kylo.getEmailAddress());
+	}
+
+	@Test // GH-3830
+	void shouldApplyInterfaceProjectionSlice() {
+
+		Slice<UserRepository.EmailOnly> result = fragment.findProjectedSliceByEmailAddress(kylo.getEmailAddress(),
+				PageRequest.of(0, 1));
+
+		assertThat(result).hasSize(1);
+		assertThat(result.getContent().get(0).getEmailAddress()).isEqualTo(kylo.getEmailAddress());
+	}
+
+	@Test // GH-3830
+	void shouldApplyInterfaceProjectionToDerivedQueryStream() {
+
+		Stream<UserRepository.EmailOnly> result = fragment.streamProjectedByEmailAddress(kylo.getEmailAddress());
+
+		assertThat(result).hasSize(1).map(UserRepository.EmailOnly::getEmailAddress).contains(kylo.getEmailAddress());
+	}
+
+	@Test // GH-3830
+	void shouldApplyAnnotatedQueryInterfaceProjection() {
+
+		UserRepository.EmailOnly result = fragment.findAnnotatedEmailProjectionByEmailAddress(kylo.getEmailAddress());
+
+		assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress());
+	}
+
+	@Test // GH-3830
+	void shouldApplyAnnotatedInterfaceProjectionQueryPage() {
+
+		Page<UserRepository.EmailOnly> result = fragment.findAnnotatedProjectedPageByEmailAddress(kylo.getEmailAddress(),
+				PageRequest.of(0, 1));
+
+		assertThat(result).hasSize(1);
+		assertThat(result.getContent().get(0).getEmailAddress()).isEqualTo(kylo.getEmailAddress());
+	}
+
+	@Test // GH-3830
+	void shouldApplyNativeInterfaceProjection() {
+
+		UserRepository.EmailOnly result = fragment.findEmailProjectionByNativeQuery(kylo.getId());
+
+		assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress());
+	}
+
+	@Test // GH-3830
+	void shouldApplyNamedQueryInterfaceProjection() {
+
+		UserRepository.EmailOnly result = fragment.findNamedProjectionEmailAddress(kylo.getEmailAddress());
+
+		assertThat(result.getEmailAddress()).isEqualTo(kylo.getEmailAddress());
+	}
+
+	@Test // GH-3830
+	void testDerivedDeleteSingle() {
+
+		User result = fragment.deleteByEmailAddress("yoda@jedi.org");
+
+		assertThat(result).isNotNull().extracting(User::getEmailAddress).isEqualTo("yoda@jedi.org");
+
+		Object yodaShouldBeGone = em
+				.createQuery("SELECT u FROM %s u WHERE u.emailAddress = 'yoda@jedi.org'".formatted(User.class.getName()))
+				.getSingleResultOrNull();
+		assertThat(yodaShouldBeGone).isNull();
+	}
+
+	@Test // GH-3830
+	void shouldOmitAnnotatedDeleteReturningDomainType() {
+
+		assertThatException().isThrownBy(() -> fragment.deleteAnnotatedQueryByEmailAddress("foo"))
+				.withRootCauseInstanceOf(NoSuchMethodException.class);
+	}
+
+	@Test // GH-3830
+	void shouldApplyModifying() {
+
+		int affected = fragment.renameAllUsersTo("Jones");
+
+		assertThat(affected).isEqualTo(7);
+
+		Object yodaShouldBeGone = em
+				.createQuery("SELECT u FROM %s u WHERE u.lastname = 'n/a'".formatted(User.class.getName()))
+				.getSingleResultOrNull();
+		assertThat(yodaShouldBeGone).isNull();
+	}
+
+	@Test // GH-3830
+	void nativeQuery() {
+
+		Page<String> page = fragment.findByNativeQueryWithPageable(PageRequest.of(0, 2));
+
+		assertThat(page.getTotalElements()).isEqualTo(7);
+		assertThat(page.getSize()).isEqualTo(2);
+		assertThat(page.getContent()).containsExactly("Anakin", "Ben");
+	}
+
+	@Test // GH-3830
+	void shouldUseNamedQuery() {
+
+		User user = fragment.findByEmailAddress("luke@jedi.org");
+		assertThat(user.getLastname()).isEqualTo("Skywalker");
+	}
+
+	@Test // GH-3830
+	void shouldUseNamedQueryAndDeriveCountQuery() {
+
+		Page<User> user = fragment.findPagedByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org");
+
+		assertThat(user).hasSize(1);
+		assertThat(user.getTotalElements()).isEqualTo(1);
+	}
+
+	@Test // GH-3830
+	void shouldUseNamedQueryAndProvidedCountQuery() {
+
+		Page<User> user = fragment.findPagedWithCountByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org");
+
+		assertThat(user).hasSize(1);
+		assertThat(user.getTotalElements()).isEqualTo(1);
+	}
+
+	@Test // GH-3830
+	void shouldUseNamedQueryAndNamedCountQuery() {
+
+		Page<User> user = fragment.findPagedWithNamedCountByEmailAddress(PageRequest.of(0, 1), "luke@jedi.org");
+
+		assertThat(user).hasSize(1);
+		assertThat(user.getTotalElements()).isEqualTo(1);
+	}
+
+	@Test // GH-3830
+	void shouldApplyQueryHints() {
+		assertThatIllegalArgumentException().isThrownBy(() -> fragment.findHintedByLastname("Skywalker"))
+				.withMessageContaining("No enum constant jakarta.persistence.CacheStoreMode.foo");
+	}
+
+	@Test // GH-3830
+	void shouldApplyNamedEntityGraph() {
+
+		User chewie = fragment.findWithNamedEntityGraphByFirstname("Chewbacca");
+
+		assertThat(chewie.getManager()).isInstanceOf(HibernateProxy.class);
+		assertThat(chewie.getRoles()).isNotInstanceOf(HibernateProxy.class);
+	}
+
+	@Test // GH-3830
+	void shouldApplyDeclaredEntityGraph() {
+
+		User chewie = fragment.findWithDeclaredEntityGraphByFirstname("Chewbacca");
+
+		assertThat(chewie.getRoles()).isNotInstanceOf(HibernateProxy.class);
+
+		User han = chewie.getManager();
+		assertThat(han.getRoles()).isNotInstanceOf(HibernateProxy.class);
+		assertThat(han.getManager()).isInstanceOf(HibernateProxy.class);
+	}
+
+	@Test // GH-3830
+	void shouldQuerySubtype() {
+
+		SpecialUser snoopy = new SpecialUser();
+		snoopy.setFirstname("Snoopy");
+		snoopy.setLastname("n/a");
+		snoopy.setEmailAddress("dog@home.com");
+		em.persist(snoopy);
+
+		SpecialUser result = fragment.findByEmailAddress("dog@home.com", SpecialUser.class);
+
+		assertThat(result).isNotNull();
+		assertThat(result).isInstanceOf(SpecialUser.class);
+	}
+
+	@Test // GH-3830
+	void shouldApplyQueryRewriter() {
+
+		User result = fragment.findAndApplyQueryRewriter(kylo.getEmailAddress());
+
+		assertThat(result).isNotNull();
+
+		Page<User> page = fragment.findAndApplyQueryRewriter(kylo.getEmailAddress(), Pageable.unpaged());
+
+		assertThat(page).isNotEmpty();
+	}
+
+	void todo() {
+
+		// dynamic projections: Not implemented
+		// keyset scrolling
+
+	}
+
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java
new file mode 100644
index 0000000000..0a65cd5c32
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryMetadataIntegrationTests.java
@@ -0,0 +1,178 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import static net.javacrumbs.jsonunit.assertj.JsonAssertions.*;
+import static org.assertj.core.api.Assertions.*;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.support.AbstractApplicationContext;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.UrlResource;
+import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Integration tests for the {@link UserRepository} JSON metadata via {@link JpaRepositoryContributor}.
+ *
+ * @author Mark Paluch
+ */
+@SpringJUnitConfig(classes = JpaRepositoryMetadataIntegrationTests.JpaRepositoryContributorConfiguration.class)
+@Transactional
+class JpaRepositoryMetadataIntegrationTests {
+
+	@Autowired AbstractApplicationContext context;
+
+	@Configuration
+	static class JpaRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport {
+		public JpaRepositoryContributorConfiguration() {
+			super(UserRepository.class);
+		}
+	}
+
+	@Test // GH-3830
+	void shouldDocumentBase() throws IOException {
+
+		Resource resource = getResource();
+
+		assertThat(resource).isNotNull();
+		assertThat(resource.exists()).isTrue();
+
+		String json = resource.getContentAsString(StandardCharsets.UTF_8);
+
+		assertThatJson(json).isObject() //
+				.containsEntry("name", UserRepository.class.getName()) //
+				.containsEntry("module", "JPA") //
+				.containsEntry("type", "IMPERATIVE");
+	}
+
+	@Test // GH-3830
+	void shouldDocumentDerivedQuery() throws IOException {
+
+		Resource resource = getResource();
+
+		assertThat(resource).isNotNull();
+		assertThat(resource.exists()).isTrue();
+
+		String json = resource.getContentAsString(StandardCharsets.UTF_8);
+
+		assertThatJson(json).inPath("$.methods[0]").isObject().containsEntry("name", "countUsersByLastname");
+		assertThatJson(json).inPath("$.methods[0].query").isObject().containsEntry("query",
+				"SELECT COUNT(u) FROM org.springframework.data.jpa.domain.sample.User u WHERE u.lastname = :lastname");
+	}
+
+	@Test // GH-3830
+	void shouldDocumentPagedQuery() throws IOException {
+
+		Resource resource = getResource();
+
+		assertThat(resource).isNotNull();
+		assertThat(resource.exists()).isTrue();
+
+		String json = resource.getContentAsString(StandardCharsets.UTF_8);
+
+		assertThatJson(json).inPath("$.methods[?(@.name == 'findAndApplyQueryRewriter')].query").isArray().element(1)
+				.isObject().containsEntry("query", "select u from OTHER u where u.emailAddress = ?1")
+				.containsEntry("count-query", "select count(u) from OTHER u where u.emailAddress = ?1");
+	}
+
+	@Test // GH-3830
+	void shouldDocumentQueryWithExpression() throws IOException {
+
+		Resource resource = getResource();
+
+		assertThat(resource).isNotNull();
+		assertThat(resource.exists()).isTrue();
+
+		String json = resource.getContentAsString(StandardCharsets.UTF_8);
+
+		assertThatJson(json).inPath("$.methods[?(@.name == 'findValueExpressionNamedByEmailAddress')].query").isArray()
+				.first().isObject().containsEntry("query", "select u from User u where u.emailAddress = :__$synthetic$__1");
+	}
+
+	@Test // GH-3830
+	void shouldDocumentNamedQuery() throws IOException {
+
+		Resource resource = getResource();
+
+		assertThat(resource).isNotNull();
+		assertThat(resource.exists()).isTrue();
+
+		String json = resource.getContentAsString(StandardCharsets.UTF_8);
+
+		assertThatJson(json).inPath("$.methods[?(@.name == 'findPagedWithNamedCountByEmailAddress')].query").isArray()
+				.first().isObject().containsEntry("name", "User.findByEmailAddress")
+				.containsEntry("query", "SELECT u FROM User u WHERE u.emailAddress = ?1")
+				.containsEntry("count-name", "User.findByEmailAddress.count-provided")
+				.containsEntry("count-query", "SELECT count(u) FROM User u WHERE u.emailAddress = ?1");
+	}
+
+	@Test // GH-3830
+	void shouldDocumentNamedProcedure() throws IOException {
+
+		Resource resource = getResource();
+
+		assertThat(resource).isNotNull();
+		assertThat(resource.exists()).isTrue();
+
+		String json = resource.getContentAsString(StandardCharsets.UTF_8);
+
+		assertThatJson(json).inPath("$.methods[?(@.name == 'namedProcedure')].query").isArray().first().isObject()
+				.containsEntry("procedure-name", "User.plus1IO");
+	}
+
+	@Test // GH-3830
+	void shouldDocumentProvidedProcedure() throws IOException {
+
+		Resource resource = getResource();
+
+		assertThat(resource).isNotNull();
+		assertThat(resource.exists()).isTrue();
+
+		String json = resource.getContentAsString(StandardCharsets.UTF_8);
+
+		assertThatJson(json).inPath("$.methods[?(@.name == 'providedProcedure')].query").isArray().first().isObject()
+				.containsEntry("procedure", "sp_add");
+	}
+
+	@Test // GH-3830
+	void shouldDocumentBaseFragment() throws IOException {
+
+		Resource resource = getResource();
+
+		assertThat(resource).isNotNull();
+		assertThat(resource.exists()).isTrue();
+
+		String json = resource.getContentAsString(StandardCharsets.UTF_8);
+
+		assertThatJson(json).inPath("$.methods[?(@.name == 'existsById')].fragment").isArray().first().isObject()
+				.containsEntry("fragment", "org.springframework.data.jpa.repository.support.SimpleJpaRepository");
+	}
+
+	private Resource getResource() {
+
+		String location = UserRepository.class.getPackageName().replace('.', '/') + "/"
+				+ UserRepository.class.getSimpleName() + ".json";
+		return new UrlResource(context.getBeanFactory().getBeanClassLoader().getResource(location));
+	}
+
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaParentRepositoryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QuerydslUserRepository.java
similarity index 55%
rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaParentRepositoryIntegrationTests.java
rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QuerydslUserRepository.java
index d94ed598c0..6c551c482d 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaParentRepositoryIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QuerydslUserRepository.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2013-2025 the original author or authors.
+ * Copyright 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.
@@ -13,15 +13,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.springframework.data.jpa.repository;
+package org.springframework.data.jpa.repository.aot;
 
-import org.junit.jupiter.api.Disabled;
-import org.springframework.test.context.ContextConfiguration;
+import java.util.List;
 
-@ContextConfiguration("classpath:openjpa.xml")
-class OpenJpaParentRepositoryIntegrationTests extends ParentRepositoryIntegrationTests {
+import org.springframework.data.jpa.domain.sample.User;
+import org.springframework.data.querydsl.QuerydslPredicateExecutor;
+import org.springframework.data.repository.CrudRepository;
+
+interface QuerydslUserRepository extends CrudRepository<User, Integer>, QuerydslPredicateExecutor<User> {
+
+	List<User> findUserNoArgumentsBy();
 
-	@Override
-	@Disabled
-	void testWithJoin() {}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/StubRepositoryInformation.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/StubRepositoryInformation.java
new file mode 100644
index 0000000000..589b95a5f7
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/StubRepositoryInformation.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Set;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.data.jpa.repository.support.SimpleJpaRepository;
+import org.springframework.data.repository.core.CrudMethods;
+import org.springframework.data.repository.core.RepositoryInformation;
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.AbstractRepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryComposition;
+import org.springframework.data.repository.core.support.RepositoryFragment;
+import org.springframework.data.util.TypeInformation;
+
+/**
+ * @author Christoph Strobl
+ */
+class StubRepositoryInformation implements RepositoryInformation {
+
+	private final RepositoryMetadata metadata;
+	private final RepositoryComposition baseComposition;
+
+	public StubRepositoryInformation(Class<?> repositoryInterface, @Nullable RepositoryComposition composition) {
+
+		this.metadata = AbstractRepositoryMetadata.getMetadata(repositoryInterface);
+		this.baseComposition = composition != null ? composition
+				: RepositoryComposition.of(RepositoryFragment.structural(SimpleJpaRepository.class));
+	}
+
+	@Override
+	public TypeInformation<?> getIdTypeInformation() {
+		return metadata.getIdTypeInformation();
+	}
+
+	@Override
+	public TypeInformation<?> getDomainTypeInformation() {
+		return metadata.getDomainTypeInformation();
+	}
+
+	@Override
+	public Class<?> getRepositoryInterface() {
+		return metadata.getRepositoryInterface();
+	}
+
+	@Override
+	public TypeInformation<?> getReturnType(Method method) {
+		return metadata.getReturnType(method);
+	}
+
+	@Override
+	public Class<?> getReturnedDomainClass(Method method) {
+		return metadata.getReturnedDomainClass(method);
+	}
+
+	@Override
+	public CrudMethods getCrudMethods() {
+		return metadata.getCrudMethods();
+	}
+
+	@Override
+	public boolean isPagingRepository() {
+		return false;
+	}
+
+	@Override
+	public Set<Class<?>> getAlternativeDomainTypes() {
+		return null;
+	}
+
+	@Override
+	public boolean isReactiveRepository() {
+		return false;
+	}
+
+	@Override
+	public Set<RepositoryFragment<?>> getFragments() {
+		return null;
+	}
+
+	@Override
+	public boolean isBaseClassMethod(Method method) {
+		return baseComposition.findMethod(method).isPresent();
+	}
+
+	@Override
+	public boolean isCustomMethod(Method method) {
+		return false;
+	}
+
+	@Override
+	public boolean isQueryMethod(Method method) {
+
+		if (isBaseClassMethod(method)) {
+			return false;
+		}
+
+		return true;
+	}
+
+	@Override
+	public List<Method> getQueryMethods() {
+		return null;
+	}
+
+	@Override
+	public Class<?> getRepositoryBaseClass() {
+		return SimpleJpaRepository.class;
+	}
+
+	@Override
+	public Method getTargetClassMethod(Method method) {
+		return null;
+	}
+
+	@Override
+	public RepositoryComposition getRepositoryComposition() {
+		return baseComposition;
+	}
+
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/TestJpaAotRepositoryContext.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/TestJpaAotRepositoryContext.java
new file mode 100644
index 0000000000..6fc63defab
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/TestJpaAotRepositoryContext.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import jakarta.persistence.Entity;
+import jakarta.persistence.MappedSuperclass;
+
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+import java.util.List;
+import java.util.Set;
+
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.core.env.Environment;
+import org.springframework.core.env.StandardEnvironment;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.core.test.tools.ClassFile;
+import org.springframework.data.jpa.domain.sample.Role;
+import org.springframework.data.jpa.domain.sample.User;
+import org.springframework.data.repository.config.AotRepositoryContext;
+import org.springframework.data.repository.core.RepositoryInformation;
+import org.springframework.data.repository.core.support.RepositoryComposition;
+import org.springframework.lang.Nullable;
+
+/**
+ * Test {@link AotRepositoryContext} implementation for JPA repositories.
+ *
+ * @author Christoph Strobl
+ */
+public class TestJpaAotRepositoryContext<T> implements AotRepositoryContext {
+
+	private final StubRepositoryInformation repositoryInformation;
+	private final Class<T> repositoryInterface;
+
+	public TestJpaAotRepositoryContext(Class<T> repositoryInterface, @Nullable RepositoryComposition composition) {
+		this.repositoryInterface = repositoryInterface;
+		this.repositoryInformation = new StubRepositoryInformation(repositoryInterface, composition);
+	}
+
+	public Class<T> getRepositoryInterface() {
+		return repositoryInterface;
+	}
+
+	@Override
+	public ConfigurableListableBeanFactory getBeanFactory() {
+		return null;
+	}
+
+	@Override
+	public Environment getEnvironment() {
+		return new StandardEnvironment();
+	}
+
+	@Override
+	public TypeIntrospector introspectType(String typeName) {
+		return null;
+	}
+
+	@Override
+	public IntrospectedBeanDefinition introspectBeanDefinition(String beanName) {
+		return null;
+	}
+
+	@Override
+	public String getBeanName() {
+		return "dummyRepository";
+	}
+
+	@Override
+	public String getModuleName() {
+		return "JPA";
+	}
+
+	@Override
+	public Set<String> getBasePackages() {
+		return Set.of("org.springframework.data.dummy.repository.aot");
+	}
+
+	@Override
+	public Set<Class<? extends Annotation>> getIdentifyingAnnotations() {
+		return Set.of(Entity.class, MappedSuperclass.class);
+	}
+
+	@Override
+	public RepositoryInformation getRepositoryInformation() {
+		return repositoryInformation;
+	}
+
+	@Override
+	public Set<MergedAnnotation<Annotation>> getResolvedAnnotations() {
+		return Set.of();
+	}
+
+	@Override
+	public Set<Class<?>> getResolvedTypes() {
+		return Set.of(User.class, Role.class);
+	}
+
+	public List<ClassFile> getRequiredContextFiles() {
+		return List.of(classFileForType(repositoryInformation.getRepositoryBaseClass()));
+	}
+
+	static ClassFile classFileForType(Class<?> type) {
+
+		String name = type.getName();
+		ClassPathResource cpr = new ClassPathResource(name.replaceAll("\\.", "/") + ".class");
+
+		try {
+			return ClassFile.of(name, cpr.getContentAsByteArray());
+		} catch (IOException e) {
+			throw new IllegalArgumentException("Cannot open [%s].".formatted(cpr.getPath()));
+		}
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaUserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserDtoProjection.java
similarity index 51%
rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaUserRepositoryFinderTests.java
rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserDtoProjection.java
index d1e1b01f66..3e8e974500 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaUserRepositoryFinderTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserDtoProjection.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2011-2025 the original author or authors.
+ * Copyright 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.
@@ -13,21 +13,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-package org.springframework.data.jpa.repository;
-
-import org.junit.jupiter.api.Disabled;
-import org.springframework.test.context.ContextConfiguration;
+package org.springframework.data.jpa.repository.aot;
 
 /**
- * Ignores some test cases using IN queries as long as we wait for fix for
- * https://bugs.eclipse.org/bugs/show_bug.cgi?id=349477.
- *
- * @author Oliver Gierke
+ * @author Christoph Strobl
+ * @since 2025/01
  */
-@ContextConfiguration("classpath:openjpa.xml")
-class OpenJpaUserRepositoryFinderTests extends UserRepositoryFinderTests {
+public class UserDtoProjection {
+
+    private final String firstname;
+    private final String emailAddress;
+
+    public UserDtoProjection(String firstname, String emailAddress) {
+        this.firstname = firstname;
+        this.emailAddress = emailAddress;
+    }
+
+    public String getFirstname() {
+        return firstname;
+    }
 
-	@Disabled
-	@Override
-	void findsByLastnameIgnoringCaseLike() {}
+    public String getEmailAddress() {
+        return emailAddress;
+    }
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java
new file mode 100644
index 0000000000..d53facc7ec
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserRepository.java
@@ -0,0 +1,280 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.aot;
+
+import jakarta.persistence.QueryHint;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+import org.springframework.data.domain.Limit;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Slice;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.domain.sample.User;
+import org.springframework.data.jpa.repository.EntityGraph;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.NativeQuery;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.jpa.repository.QueryHints;
+import org.springframework.data.jpa.repository.QueryRewriter;
+import org.springframework.data.jpa.repository.query.Procedure;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.data.repository.query.Param;
+
+/**
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ */
+interface UserRepository extends CrudRepository<User, Integer> {
+
+	List<User> findUserNoArgumentsBy();
+
+	User findOneByEmailAddress(String emailAddress);
+
+	Optional<User> findOptionalOneByEmailAddress(String emailAddress);
+
+	Long countUsersByLastname(String lastname);
+
+	boolean existsUserByLastname(String lastname);
+
+	List<User> findByLastnameStartingWith(String lastname);
+
+	List<User> findTop2ByLastnameStartingWith(String lastname);
+
+	List<User> findByLastnameStartingWithOrderByEmailAddress(String lastname);
+
+	List<User> findByLastnameStartingWith(String lastname, Limit limit);
+
+	List<User> findByLastnameStartingWith(String lastname, Sort sort);
+
+	List<User> findByLastnameStartingWith(String lastname, Sort sort, Limit limit);
+
+	List<User> findByLastnameStartingWith(String lastname, Pageable page);
+
+	Page<User> findPageOfUsersByLastnameStartingWith(String lastname, Pageable page);
+
+	Slice<User> findSliceOfUserByLastnameStartingWith(String lastname, Pageable page);
+
+	Stream<User> streamByLastnameLike(String lastname);
+
+	// -------------------------------------------------------------------------
+	// Declared Queries
+	// -------------------------------------------------------------------------
+
+	@Query("select u from User u where u.emailAddress = ?1")
+	User findAnnotatedQueryByEmailAddress(String username);
+
+	@Query("select u from User u where u.lastname like ?1%")
+	List<User> findAnnotatedQueryByLastname(String lastname);
+
+	@Query("select u from User u where u.lastname like :lastname%")
+	List<User> findAnnotatedQueryByLastnameParameter(String lastname);
+
+	@Query("select u from User u where u.lastname like :lastname% or u.lastname like %:lastname")
+	List<User> findAnnotatedLikeStartsEnds(String lastname);
+
+	@Query("""
+			select u
+			from User u
+			where u.lastname LIKE ?1%""")
+	List<User> findAnnotatedMultilineQueryByLastname(String username);
+
+	@Query("select u from User u where u.lastname like ?1%")
+	List<User> findAnnotatedQueryByLastname(String lastname, Limit limit);
+
+	@Query("select u from User u where u.lastname like ?1%")
+	List<User> findAnnotatedQueryByLastname(String lastname, Sort sort);
+
+	@Query("select u from User u where u.lastname like ?1%")
+	List<User> findAnnotatedQueryByLastname(String lastname, Limit limit, Sort sort);
+
+	// nasty parameter names
+	@Query("select u from User u where u.lastname like ?1%")
+	List<User> findAnnotatedQueryByLastname(String query, Pageable queryString);
+
+	@Query("select u from User u where u.lastname like ?1%")
+	Page<User> findAnnotatedQueryPageOfUsersByLastname(String lastname, Pageable pageable);
+
+	@Query("select u from User u where u.lastname like ?1% ORDER BY u.lastname")
+	Page<User> findAnnotatedQueryPageWithStaticSort(String lastname, Pageable pageable);
+
+	@Query("select u from User u where u.lastname like ?1%")
+	Slice<User> findAnnotatedQuerySliceOfUsersByLastname(String lastname, Pageable pageable);
+
+	// -------------------------------------------------------------------------
+	// Projections: Parameter naming
+	// -------------------------------------------------------------------------
+
+	@Query("select u from User u where u.lastname like %:name or u.lastname like :name% ORDER BY u.lastname")
+	List<User> findAnnotatedWithParameterNameQuery(@Param("name") String lastname);
+
+	List<User> findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(@Param("l1") String l1,
+			@Param("l2") String l2);
+
+	// -------------------------------------------------------------------------
+	// Value Expressions
+	// -------------------------------------------------------------------------
+
+	@Query("select u from #{#entityName} u where u.emailAddress = ?1")
+	User findTemplatedByEmailAddress(String emailAddress);
+
+	@Query("select u from User u where u.emailAddress = :#{#emailAddress}")
+	User findValueExpressionNamedByEmailAddress(String emailAddress);
+
+	@Query("select u from User u where u.emailAddress = ?#{[0]} or u.firstname = ?${user.dir}")
+	User findValueExpressionPositionalByEmailAddress(String emailAddress);
+
+	// -------------------------------------------------------------------------
+	// Projections: DTO
+	// -------------------------------------------------------------------------
+
+	List<UserDtoProjection> findUserProjectionByLastnameStartingWith(String lastname);
+
+	Page<UserDtoProjection> findUserProjectionByLastnameStartingWith(String lastname, Pageable page);
+
+	Names findDtoByEmailAddress(String emailAddress);
+
+	Page<Names> findDtoPageByEmailAddress(String emailAddress, Pageable pageable);
+
+	@Query("select u from User u where u.emailAddress = ?1")
+	Names findAnnotatedDtoEmailAddress(String emailAddress);
+
+	@Query("select u from User u where u.emailAddress = ?1")
+	Page<Names> findAnnotatedDtoPageByEmailAddress(String emailAddress, Pageable pageable);
+
+	@NativeQuery(value = "SELECT emailaddress, secondary_email_address FROM SD_User WHERE id = ?1",
+			sqlResultSetMapping = "emailDto")
+	User.EmailDto findEmailDtoByNativeQuery(Integer id);
+
+	@Query(name = "User.findByEmailAddress")
+	Names findNamedDtoEmailAddress(String emailAddress);
+
+	// -------------------------------------------------------------------------
+	// Projections: Interface
+	// -------------------------------------------------------------------------
+
+	EmailOnly findEmailProjectionById(Integer id);
+
+	Page<EmailOnly> findProjectedPageByEmailAddress(String emailAddress, Pageable page);
+
+	Slice<EmailOnly> findProjectedSliceByEmailAddress(String lastname, Pageable page);
+
+	Stream<EmailOnly> streamProjectedByEmailAddress(String lastname);
+
+	@Query("select u from User u where u.emailAddress = ?1")
+	EmailOnly findAnnotatedEmailProjectionByEmailAddress(String emailAddress);
+
+	@Query("select u from User u where u.emailAddress = ?1")
+	Page<EmailOnly> findAnnotatedProjectedPageByEmailAddress(String emailAddress, Pageable page);
+
+	@NativeQuery(value = "SELECT emailaddress as emailAddress FROM SD_User WHERE id = ?1")
+	EmailOnly findEmailProjectionByNativeQuery(Integer id);
+
+	@Query(name = "User.findByEmailAddress")
+	EmailOnly findNamedProjectionEmailAddress(String emailAddress);
+
+	// -------------------------------------------------------------------------
+	// Modifying
+	// -------------------------------------------------------------------------
+
+	User deleteByEmailAddress(String username);
+
+	// cannot generate delete and return a domain object
+	@Modifying
+	@Query("delete from User u where u.emailAddress = ?1")
+	User deleteAnnotatedQueryByEmailAddress(String username);
+
+	@Modifying(flushAutomatically = true, clearAutomatically = true)
+	@Query("update User u set u.lastname = ?1")
+	int renameAllUsersTo(String lastname);
+
+	// -------------------------------------------------------------------------
+	// Native Queries
+	// -------------------------------------------------------------------------
+
+	@Query(value = "SELECT firstname FROM SD_User ORDER BY UCASE(firstname)", countQuery = "SELECT count(*) FROM SD_User",
+			nativeQuery = true)
+	Page<String> findByNativeQueryWithPageable(Pageable pageable);
+
+	// -------------------------------------------------------------------------
+	// Named Queries
+	// -------------------------------------------------------------------------
+
+	User findByEmailAddress(String emailAddress);
+
+	@Query(name = "User.findByEmailAddress")
+	Page<User> findPagedByEmailAddress(Pageable pageable, String emailAddress);
+
+	@Query(name = "User.findByEmailAddress", countQuery = "SELECT CoUnT(u) FROM User u WHERE u.emailAddress = ?1")
+	Page<User> findPagedWithCountByEmailAddress(Pageable pageable, String emailAddress);
+
+	@Query(name = "User.findByEmailAddress", countName = "User.findByEmailAddress.count-provided")
+	Page<User> findPagedWithNamedCountByEmailAddress(Pageable pageable, String emailAddress);
+
+	// -------------------------------------------------------------------------
+	// Query Hints
+	// -------------------------------------------------------------------------
+
+	@QueryHints(value = { @QueryHint(name = "jakarta.persistence.cache.storeMode", value = "foo") }, forCounting = false)
+	List<User> findHintedByLastname(String lastname);
+
+	@EntityGraph(type = EntityGraph.EntityGraphType.FETCH, value = "User.overview")
+	User findWithNamedEntityGraphByFirstname(String firstname);
+
+	@EntityGraph(type = EntityGraph.EntityGraphType.FETCH, attributePaths = { "roles", "manager.roles" })
+	User findWithDeclaredEntityGraphByFirstname(String firstname);
+
+	@Query("select u from User u where u.emailAddress = ?1 AND TYPE(u) = ?2")
+	<T extends User> T findByEmailAddress(String emailAddress, Class<T> type);
+
+	@Query(value = "select u from PLACEHOLDER u where u.emailAddress = ?1", queryRewriter = MyQueryRewriter.class)
+	User findAndApplyQueryRewriter(String emailAddress);
+
+	@Query(value = "select u from OTHER u where u.emailAddress = ?1", queryRewriter = MyQueryRewriter.class)
+	Page<User> findAndApplyQueryRewriter(String emailAddress, Pageable pageable);
+
+	// -------------------------------------------------------------------------
+	// Unsupported: Procedures
+	// -------------------------------------------------------------------------
+	@Procedure(name = "User.plus1IO") // Named
+	Integer namedProcedure(@Param("arg") Integer arg);
+
+	@Procedure(value = "sp_add") // Stored procedure
+	Integer providedProcedure(@Param("arg") Integer arg);
+
+	interface EmailOnly {
+		String getEmailAddress();
+	}
+
+	record Names(String firstname, String lastname) {
+	}
+
+	static class MyQueryRewriter implements QueryRewriter {
+
+		@Override
+		public String rewrite(String query, Sort sort) {
+			return query.replaceAll("PLACEHOLDER", "User");
+		}
+
+		@Override
+		public String rewrite(String query, Pageable pageRequest) {
+			return query.replaceAll("OTHER", "User");
+		}
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractAuditingViaJavaConfigRepositoriesTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractAuditingViaJavaConfigRepositoriesTests.java
index 5285ed2e3e..3073cc420e 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractAuditingViaJavaConfigRepositoriesTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractAuditingViaJavaConfigRepositoriesTests.java
@@ -20,9 +20,9 @@
 
 import jakarta.persistence.EntityManager;
 
+import java.time.Instant;
 import java.time.LocalDateTime;
 import java.time.ZoneId;
-import java.util.Date;
 import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
@@ -54,6 +54,7 @@
  * @author Oliver Gierke
  * @author Jens Schauder
  * @author Krzysztof Krason
+ * @author Christoph Strobl
  */
 @ExtendWith(SpringExtension.class)
 @Transactional
@@ -111,13 +112,13 @@ void shouldAllowUseOfDynamicSpelParametersInUpdateQueries() {
 		em.detach(thomas);
 		em.detach(auditor);
 
-		FixedDate.INSTANCE.setDate(new Date());
+		FixedDate.INSTANCE.setDate(Instant.now());
 
 		SampleSecurityContextHolder.getCurrent().setPrincipal(thomas);
 		auditableUserRepository.updateAllNamesToUpperCase();
 
 		// DateTime now = new DateTime(FixedDate.INSTANCE.getDate());
-		LocalDateTime now = LocalDateTime.ofInstant(FixedDate.INSTANCE.getDate().toInstant(), ZoneId.systemDefault());
+		LocalDateTime now = LocalDateTime.ofInstant(FixedDate.INSTANCE.getDate(), ZoneId.systemDefault());
 		List<AuditableUser> users = auditableUserRepository.findAll();
 
 		for (AuditableUser user : users) {
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryRegistrationAotProcessorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryRegistrationAotProcessorUnitTests.java
index 714abc2afa..44c260dcb5 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryRegistrationAotProcessorUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryRegistrationAotProcessorUnitTests.java
@@ -31,6 +31,8 @@
 import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
 import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
 import org.springframework.core.annotation.MergedAnnotation;
+import org.springframework.core.env.Environment;
+import org.springframework.core.env.StandardEnvironment;
 import org.springframework.data.repository.config.AotRepositoryContext;
 import org.springframework.data.repository.core.RepositoryInformation;
 import org.springframework.javapoet.ClassName;
@@ -86,6 +88,11 @@ public String getBeanName() {
 			return "jpaRepository";
 		}
 
+		@Override
+		public String getModuleName() {
+			return "JPA";
+		}
+
 		@Override
 		public Set<String> getBasePackages() {
 			return Collections.singleton(this.getClass().getPackageName());
@@ -116,6 +123,11 @@ public ConfigurableListableBeanFactory getBeanFactory() {
 			return null;
 		}
 
+		@Override
+		public Environment getEnvironment() {
+			return new StandardEnvironment();
+		}
+
 		@Override
 		public TypeIntrospector introspectType(String typeName) {
 			return null;
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureIntegrationTests.java
index af07eb0013..a88e23f9a6 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureIntegrationTests.java
@@ -106,7 +106,8 @@ void testNamedOutputParameter() {
 				new Employee(4, "Gabriel"));
 	}
 
-	@DisabledOnHibernate("6")
+	@DisabledOnHibernate(value = "7",
+			disabledReason = "class org.hibernate.metamodel.model.domain.internal.EntityTypeImpl cannot be cast to class org.hibernate.query.OutputableType (org.hibernate.metamodel.model.domain.internal.EntityTypeImpl and org.hibernate.query.OutputableType are in unnamed module of loader 'app')")
 	@Test // 2256
 	void testSingleEntityFromResultSet() {
 
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractJpaQueryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractJpaQueryTests.java
index 8728e03229..fdcbabf84b 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractJpaQueryTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractJpaQueryTests.java
@@ -230,6 +230,11 @@ protected Query doCreateQuery(JpaParametersParameterAccessor accessor) {
 			return query;
 		}
 
+		@Override
+		public boolean hasDeclaredCountQuery() {
+			return true;
+		}
+
 		@Override
 		protected TypedQuery<Long> doCreateCountQuery(JpaParametersParameterAccessor accessor) {
 			return countQuery;
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryIntegrationTests.java
index 6590db4022..3d77980fb6 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryIntegrationTests.java
@@ -34,7 +34,6 @@
 import org.springframework.data.jpa.domain.sample.User;
 import org.springframework.data.jpa.provider.PersistenceProvider;
 import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.jpa.repository.QueryRewriter;
 import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
 import org.springframework.data.repository.Repository;
 import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
@@ -53,6 +52,9 @@
 @ContextConfiguration("classpath:infrastructure.xml")
 class AbstractStringBasedJpaQueryIntegrationTests {
 
+	private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(),
+			QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT);
+
 	@PersistenceContext EntityManager em;
 
 	@Autowired BeanFactory beanFactory;
@@ -66,10 +68,10 @@ void createsNormalQueryForJpaManagedReturnTypes() throws Exception {
 		when(mock.getMetamodel()).thenReturn(em.getMetamodel());
 
 		JpaQueryMethod method = getMethod("findRolesByEmailAddress", String.class);
-		AbstractStringBasedJpaQuery jpaQuery = new SimpleJpaQuery(method, mock, null, QueryRewriter.IdentityQueryRewriter.INSTANCE,
-				ValueExpressionDelegate.create());
+		AbstractStringBasedJpaQuery jpaQuery = new SimpleJpaQuery(method, mock, method.getRequiredDeclaredQuery(), null,
+				CONFIG);
 
-		jpaQuery.createJpaQuery(method.getAnnotatedQuery(), Sort.unsorted(), null,
+		jpaQuery.createJpaQuery(method.getRequiredDeclaredQuery(), Sort.unsorted(), null,
 				method.getResultProcessor().getReturnedType());
 
 		verify(mock, times(1)).createQuery(anyString());
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java
index 3fb97409f8..953203134f 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java
@@ -27,6 +27,7 @@
 
 import org.assertj.core.api.Assertions;
 import org.assertj.core.util.Arrays;
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
@@ -35,7 +36,6 @@
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jpa.provider.QueryExtractor;
 import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.jpa.repository.QueryRewriter;
 import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
 import org.springframework.data.repository.Repository;
 import org.springframework.data.repository.core.RepositoryMetadata;
@@ -43,7 +43,6 @@
 import org.springframework.data.repository.query.ParametersSource;
 import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.data.repository.query.ValueExpressionDelegate;
-import org.springframework.lang.Nullable;
 import org.springframework.util.LinkedMultiValueMap;
 import org.springframework.util.MultiValueMap;
 import org.springframework.util.ReflectionUtils;
@@ -56,6 +55,9 @@
  */
 class AbstractStringBasedJpaQueryUnitTests {
 
+	private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(),
+			QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT);
+
 	@Test // GH-3310
 	void shouldNotAttemptToAppendSortIfNoSortArgumentPresent() {
 
@@ -118,8 +120,8 @@ static InvocationCapturingStringQueryStub forMethod(Class<?> repository, String
 
 		Query query = AnnotatedElementUtils.getMergedAnnotation(respositoryMethod, Query.class);
 
-		return new InvocationCapturingStringQueryStub(respositoryMethod, queryMethod, query.value(), query.countQuery());
-
+		return new InvocationCapturingStringQueryStub(respositoryMethod, queryMethod, query.value(), query.countQuery(),
+				CONFIG);
 	}
 
 	static class InvocationCapturingStringQueryStub extends AbstractStringBasedJpaQuery {
@@ -128,7 +130,7 @@ static class InvocationCapturingStringQueryStub extends AbstractStringBasedJpaQu
 		private final MultiValueMap<String, Arguments> capturedArguments = new LinkedMultiValueMap<>(3);
 
 		InvocationCapturingStringQueryStub(Method targetMethod, JpaQueryMethod queryMethod, String queryString,
-				@Nullable String countQueryString) {
+				@Nullable String countQueryString, JpaQueryConfiguration queryConfiguration) {
 			super(queryMethod, new Supplier<EntityManager>() {
 
 				@Override
@@ -142,14 +144,13 @@ public EntityManager get() {
 
 					return em;
 				}
-			}.get(), queryString, countQueryString, Mockito.mock(QueryRewriter.class),
-					ValueExpressionDelegate.create());
+			}.get(), queryString, countQueryString, queryConfiguration);
 
 			this.targetMethod = targetMethod;
 		}
 
 		@Override
-		protected String applySorting(CachableQuery query) {
+		protected QueryProvider applySorting(CachableQuery query) {
 
 			captureInvocation("applySorting", query);
 
@@ -157,12 +158,13 @@ protected String applySorting(CachableQuery query) {
 		}
 
 		@Override
-		protected jakarta.persistence.Query createJpaQuery(String queryString, Sort sort, @Nullable Pageable pageable,
+		protected jakarta.persistence.Query createJpaQuery(QueryProvider query, Sort sort,
+				@Nullable Pageable pageable,
 				ReturnedType returnedType) {
 
-			captureInvocation("createJpaQuery", queryString, sort, pageable, returnedType);
+			captureInvocation("createJpaQuery", query, sort, pageable, returnedType);
 
-			jakarta.persistence.Query jpaQuery = super.createJpaQuery(queryString, sort, pageable, returnedType);
+			jakarta.persistence.Query jpaQuery = super.createJpaQuery(query, sort, pageable, returnedType);
 			return jpaQuery == null ? Mockito.mock(jakarta.persistence.Query.class) : jpaQuery;
 		}
 
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StringQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java
similarity index 82%
rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StringQueryUnitTests.java
rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java
index 41b36b21d7..599fb05aa0 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StringQueryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java
@@ -17,7 +17,6 @@
 
 import static org.assertj.core.api.Assertions.*;
 
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 
@@ -32,7 +31,7 @@
 import org.springframework.data.repository.query.parser.Part.Type;
 
 /**
- * Unit tests for {@link StringQuery}.
+ * Unit tests for {@link DefaultEntityQuery}.
  *
  * @author Oliver Gierke
  * @author Thomas Darimont
@@ -42,14 +41,15 @@
  * @author Diego Krupitza
  * @author Mark Paluch
  * @author Aleksei Elin
+ * @author Gunha Hwang
  */
-class StringQueryUnitTests {
+class DefaultEntityQueryUnitTests {
 
 	@Test // DATAJPA-341
 	void doesNotConsiderPlainLikeABinding() {
 
 		String source = "select u from User u where u.firstname like :firstname";
-		StringQuery query = new StringQuery(source, false);
+		DefaultEntityQuery query = new TestEntityQuery(source, false);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString()).isEqualTo(source);
@@ -66,8 +66,8 @@ void doesNotConsiderPlainLikeABinding() {
 	@Test // DATAJPA-292
 	void detectsPositionalLikeBindings() {
 
-		StringQuery query = new StringQuery("select u from User u where u.firstname like %?1% or u.lastname like %?2",
-				true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"select u from User u where u.firstname like %?1% or u.lastname like %?2", true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString())
@@ -90,7 +90,7 @@ void detectsPositionalLikeBindings() {
 	@Test // DATAJPA-292, GH-3041
 	void detectsAnonymousLikeBindings() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select u from User u where u.firstname like %?% or u.lastname like %? or u.lastname=?", true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
@@ -116,7 +116,8 @@ void detectsAnonymousLikeBindings() {
 	@Test // DATAJPA-292, GH-3041
 	void detectsNamedLikeBindings() {
 
-		StringQuery query = new StringQuery("select u from User u where u.firstname like %:firstname", true);
+		DefaultEntityQuery query = new TestEntityQuery("select u from User u where u.firstname like %:firstname",
+				true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like :firstname");
@@ -133,7 +134,7 @@ void detectsNamedLikeBindings() {
 	@Test // GH-3041
 	void rewritesNamedLikeToUniqueParametersIfNecessary() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select u from User u where u.firstname like %:firstname or u.firstname like :firstname% or u.firstname = :firstname",
 				true);
 
@@ -164,7 +165,7 @@ void rewritesNamedLikeToUniqueParametersIfNecessary() {
 	@Test // GH-3784
 	void rewritesNamedLikeToUniqueParametersRetainingCountQuery() {
 
-		DeclaredQuery query = new StringQuery(
+		ParametrizedQuery query = new TestEntityQuery(
 				"select u from User u where u.firstname like %:firstname or u.firstname like :firstname% or u.firstname = :firstname",
 				false).deriveCountQuery(null);
 
@@ -197,7 +198,7 @@ void rewritesNamedLikeToUniqueParametersRetainingCountQuery() {
 	@Test // GH-3784
 	void rewritesExpressionsLikeToUniqueParametersRetainingCountQuery() {
 
-		DeclaredQuery query = new StringQuery(
+		ParametrizedQuery query = new TestEntityQuery(
 				"select u from User u where u.firstname like %:#{firstname} or u.firstname like :#{firstname}%", false)
 				.deriveCountQuery(null);
 
@@ -224,7 +225,7 @@ void rewritesExpressionsLikeToUniqueParametersRetainingCountQuery() {
 	@Test // GH-3041
 	void rewritesPositionalLikeToUniqueParametersIfNecessary() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select u from User u where u.firstname like %?1 or u.firstname like ?1% or u.firstname = ?1", true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
@@ -238,7 +239,7 @@ void rewritesPositionalLikeToUniqueParametersIfNecessary() {
 	@Test // GH-3041
 	void reusesNamedLikeBindingsWherePossible() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select u from User u where u.firstname like %:firstname or u.firstname like %:firstname% or u.firstname like %:firstname% or u.firstname like %:firstname",
 				true);
 
@@ -246,7 +247,8 @@ void reusesNamedLikeBindingsWherePossible() {
 		assertThat(query.getQueryString()).isEqualTo(
 				"select u from User u where u.firstname like :firstname or u.firstname like :firstname_1 or u.firstname like :firstname_1 or u.firstname like :firstname");
 
-		query = new StringQuery("select u from User u where u.firstname like %:firstname or u.firstname =:firstname", true);
+		query = new TestEntityQuery(
+				"select u from User u where u.firstname like %:firstname or u.firstname =:firstname", true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString())
@@ -256,7 +258,7 @@ void reusesNamedLikeBindingsWherePossible() {
 	@Test // GH-3041
 	void reusesPositionalLikeBindingsWherePossible() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select u from User u where u.firstname like %?1 or u.firstname like %?1% or u.firstname like %?1% or u.firstname like %?1",
 				false);
 
@@ -264,7 +266,7 @@ void reusesPositionalLikeBindingsWherePossible() {
 		assertThat(query.getQueryString()).isEqualTo(
 				"select u from User u where u.firstname like ?1 or u.firstname like ?2 or u.firstname like ?2 or u.firstname like ?1");
 
-		query = new StringQuery("select u from User u where u.firstname like %?1 or u.firstname =?1", false);
+		query = new TestEntityQuery("select u from User u where u.firstname like %?1 or u.firstname =?1", false);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like ?1 or u.firstname =?2");
@@ -273,7 +275,7 @@ void reusesPositionalLikeBindingsWherePossible() {
 	@Test // GH-3041
 	void shouldRewritePositionalBindingsWithParameterReuse() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select u from User u where u.firstname like ?2 or u.firstname like %?2% or u.firstname like %?1% or u.firstname like %?1 OR u.firstname like ?1",
 				false);
 
@@ -295,8 +297,8 @@ void shouldRewritePositionalBindingsWithParameterReuse() {
 	@Test // GH-3758
 	void createsDistinctBindingsForIndexedSpel() {
 
-		StringQuery query = new StringQuery("select u from User u where u.firstname = ?#{foo} OR u.firstname = ?#{foo}",
-				false);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"select u from User u where u.firstname = ?#{foo} OR u.firstname = ?#{foo}", false);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getParameterBindings()).hasSize(2).extracting(ParameterBinding::getRequiredPosition)
@@ -309,8 +311,8 @@ void createsDistinctBindingsForIndexedSpel() {
 	@Test // GH-3758
 	void createsDistinctBindingsForNamedSpel() {
 
-		StringQuery query = new StringQuery("select u from User u where u.firstname = :#{foo} OR u.firstname = :#{foo}",
-				false);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"select u from User u where u.firstname = :#{foo} OR u.firstname = :#{foo}", false);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getParameterBindings()).hasSize(2).extracting(ParameterBinding::getOrigin)
@@ -322,7 +324,7 @@ void createsDistinctBindingsForNamedSpel() {
 	void detectsNamedInParameterBindings() {
 
 		String queryString = "select u from User u where u.id in :ids";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString()).isEqualTo(queryString);
@@ -337,7 +339,7 @@ void detectsNamedInParameterBindings() {
 	void detectsMultipleNamedInParameterBindings() {
 
 		String queryString = "select u from User u where u.id in :ids and u.name in :names and foo = :bar";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString()).isEqualTo(queryString);
@@ -354,7 +356,7 @@ void detectsMultipleNamedInParameterBindings() {
 	void deriveCountQueryWithNamedInRetainsOrigin() {
 
 		String queryString = "select u from User u where (:logins) IS NULL OR LOWER(u.login) IN (:logins)";
-		DeclaredQuery query = new StringQuery(queryString, false).deriveCountQuery(null);
+		ParametrizedQuery query = new TestEntityQuery(queryString, false).deriveCountQuery(null);
 
 		assertThat(query.getQueryString())
 				.isEqualTo("select count(u) from User u where (:logins) IS NULL OR LOWER(u.login) IN (:logins_1)");
@@ -375,7 +377,7 @@ void deriveCountQueryWithNamedInRetainsOrigin() {
 	void deriveCountQueryWithPositionalInRetainsOrigin() {
 
 		String queryString = "select u from User u where (?1) IS NULL OR LOWER(u.login) IN (?1)";
-		DeclaredQuery query = new StringQuery(queryString, false).deriveCountQuery(null);
+		ParametrizedQuery query = new TestEntityQuery(queryString, false).deriveCountQuery(null);
 
 		assertThat(query.getQueryString())
 				.isEqualTo("select count(u) from User u where (?1) IS NULL OR LOWER(u.login) IN (?2)");
@@ -396,7 +398,7 @@ void deriveCountQueryWithPositionalInRetainsOrigin() {
 	void detectsPositionalInParameterBindings() {
 
 		String queryString = "select u from User u where u.id in ?1";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString()).isEqualTo(queryString);
@@ -410,7 +412,7 @@ void detectsPositionalInParameterBindings() {
 	@Test // GH-3126
 	void allowsReuseOfParameterWithInAndRegularBinding() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select u from User u where COALESCE(?1) is null OR u.id in ?1 OR COALESCE(?1) is null OR u.id in ?1", true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
@@ -423,7 +425,7 @@ void allowsReuseOfParameterWithInAndRegularBinding() {
 		assertPositionalBinding(ParameterBinding.class, 1, bindings.get(0));
 		assertPositionalBinding(InParameterBinding.class, 2, bindings.get(1));
 
-		query = new StringQuery(
+		query = new TestEntityQuery(
 				"select u from User u where COALESCE(:foo) is null OR u.id in :foo OR COALESCE(:foo) is null OR u.id in :foo",
 				true);
 
@@ -442,7 +444,7 @@ void allowsReuseOfParameterWithInAndRegularBinding() {
 	void detectsPositionalInParameterBindingsAndExpressions() {
 
 		String queryString = "select u from User u where foo = ?#{bar} and bar = ?3 and baz = ?#{baz}";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(query.getQueryString()).isEqualTo("select u from User u where foo = ?1 and bar = ?3 and baz = ?2");
 	}
@@ -451,7 +453,7 @@ void detectsPositionalInParameterBindingsAndExpressions() {
 	void detectsPositionalInParameterBindingsAndExpressionsWithReuse() {
 
 		String queryString = "select u from User u where foo = ?#{bar} and bar = ?2 and baz = ?#{bar}";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(query.getQueryString()).isEqualTo("select u from User u where foo = ?1 and bar = ?2 and baz = ?3");
 	}
@@ -459,17 +461,17 @@ void detectsPositionalInParameterBindingsAndExpressionsWithReuse() {
 	@Test // GH-3126
 	void countQueryDerivationRetainsNamedExpressionParameters() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select u from User u where foo = :#{bar} ORDER BY CASE WHEN (u.firstname >= :#{name}) THEN 0 ELSE 1 END",
 				false);
 
-		DeclaredQuery countQuery = query.deriveCountQuery(null);
+		ParametrizedQuery countQuery = query.deriveCountQuery(null);
 
 		assertThat(countQuery.getParameterBindings()).hasSize(1);
 		assertThat(countQuery.getParameterBindings()).extracting(ParameterBinding::getOrigin)
 				.extracting(ParameterOrigin::isExpression).isEqualTo(List.of(true));
 
-		query = new StringQuery(
+		query = new TestEntityQuery(
 				"select u from User u where foo = :#{bar} and bar = :bar ORDER BY CASE WHEN (u.firstname >= :bar) THEN 0 ELSE 1 END",
 				false);
 
@@ -484,17 +486,17 @@ void countQueryDerivationRetainsNamedExpressionParameters() {
 	@Test // GH-3126
 	void countQueryDerivationRetainsIndexedExpressionParameters() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select u from User u where foo = ?#{bar} ORDER BY CASE WHEN (u.firstname >= ?#{name}) THEN 0 ELSE 1 END",
 				false);
 
-		DeclaredQuery countQuery = query.deriveCountQuery(null);
+		ParametrizedQuery countQuery = query.deriveCountQuery(null);
 
 		assertThat(countQuery.getParameterBindings()).hasSize(1);
 		assertThat(countQuery.getParameterBindings()).extracting(ParameterBinding::getOrigin)
 				.extracting(ParameterOrigin::isExpression).isEqualTo(List.of(true));
 
-		query = new StringQuery(
+		query = new TestEntityQuery(
 				"select u from User u where foo = ?#{bar} and bar = ?1 ORDER BY CASE WHEN (u.firstname >= ?1) THEN 0 ELSE 1 END",
 				false);
 
@@ -510,7 +512,7 @@ void countQueryDerivationRetainsIndexedExpressionParameters() {
 	void detectsMultiplePositionalInParameterBindings() {
 
 		String queryString = "select u from User u where u.id in ?1 and u.names in ?2 and foo = ?3";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString()).isEqualTo(queryString);
@@ -526,13 +528,13 @@ void detectsMultiplePositionalInParameterBindings() {
 
 	@Test // DATAJPA-373
 	void handlesMultipleNamedLikeBindingsCorrectly() {
-		new StringQuery("select u from User u where u.firstname like %:firstname or foo like :bar", true);
+		new TestEntityQuery("select u from User u where u.firstname like %:firstname or foo like :bar", true);
 	}
 
 	@Test // DATAJPA-461
 	void treatsGreaterThanBindingAsSimpleBinding() {
 
-		StringQuery query = new StringQuery("select u from User u where u.createdDate > ?1", true);
+		DefaultEntityQuery query = new TestEntityQuery("select u from User u where u.createdDate > ?1", true);
 		List<ParameterBinding> bindings = query.getParameterBindings();
 
 		assertThat(bindings).hasSize(1);
@@ -543,8 +545,10 @@ void treatsGreaterThanBindingAsSimpleBinding() {
 	@Test // DATAJPA-473
 	void removesLikeBindingsFromQueryIfQueryContainsSimpleBinding() {
 
-		StringQuery query = new StringQuery("SELECT a FROM Article a WHERE a.overview LIKE %:escapedWord% ESCAPE '~'"
-				+ " OR a.content LIKE %:escapedWord% ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC", true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"SELECT a FROM Article a WHERE a.overview LIKE %:escapedWord% ESCAPE '~'"
+						+ " OR a.content LIKE %:escapedWord% ESCAPE '~' OR a.title = :word ORDER BY a.articleId DESC",
+				true);
 
 		List<ParameterBinding> bindings = query.getParameterBindings();
 
@@ -559,7 +563,8 @@ void removesLikeBindingsFromQueryIfQueryContainsSimpleBinding() {
 	@Test // DATAJPA-483
 	void detectsInBindingWithParentheses() {
 
-		StringQuery query = new StringQuery("select count(we) from MyEntity we where we.status in (:statuses)", true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"select count(we) from MyEntity we where we.status in (:statuses)", true);
 
 		List<ParameterBinding> bindings = query.getParameterBindings();
 
@@ -570,7 +575,7 @@ void detectsInBindingWithParentheses() {
 	@Test // DATAJPA-545
 	void detectsInBindingWithSpecialFrenchCharactersInParentheses() {
 
-		StringQuery query = new StringQuery("select * from MyEntity where abonnés in (:abonnés)", true);
+		DefaultEntityQuery query = new TestEntityQuery("select * from MyEntity where abonnés in (:abonnés)", true);
 
 		List<ParameterBinding> bindings = query.getParameterBindings();
 
@@ -581,7 +586,7 @@ void detectsInBindingWithSpecialFrenchCharactersInParentheses() {
 	@Test // DATAJPA-545
 	void detectsInBindingWithSpecialCharactersInParentheses() {
 
-		StringQuery query = new StringQuery("select * from MyEntity where øre in (:øre)", true);
+		DefaultEntityQuery query = new TestEntityQuery("select * from MyEntity where øre in (:øre)", true);
 
 		List<ParameterBinding> bindings = query.getParameterBindings();
 
@@ -592,7 +597,7 @@ void detectsInBindingWithSpecialCharactersInParentheses() {
 	@Test // DATAJPA-545
 	void detectsInBindingWithSpecialAsianCharactersInParentheses() {
 
-		StringQuery query = new StringQuery("select * from MyEntity where 생일 in (:생일)", true);
+		DefaultEntityQuery query = new TestEntityQuery("select * from MyEntity where 생일 in (:생일)", true);
 
 		List<ParameterBinding> bindings = query.getParameterBindings();
 
@@ -603,7 +608,7 @@ void detectsInBindingWithSpecialAsianCharactersInParentheses() {
 	@Test // DATAJPA-545
 	void detectsInBindingWithSpecialCharactersAndWordCharactersMixedInParentheses() {
 
-		StringQuery query = new StringQuery("select * from MyEntity where foo in (:ab1babc생일233)", true);
+		DefaultEntityQuery query = new TestEntityQuery("select * from MyEntity where foo in (:ab1babc생일233)", true);
 
 		List<ParameterBinding> bindings = query.getParameterBindings();
 
@@ -614,7 +619,7 @@ void detectsInBindingWithSpecialCharactersAndWordCharactersMixedInParentheses()
 	@Test // DATAJPA-712, GH-3619
 	void shouldReplaceAllNamedExpressionParametersWithInClause() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select a from A a where a.b in :#{#bs} and a.c in :#{#cs} and a.d in :${foo.bar}", true);
 		String queryString = query.getQueryString();
 
@@ -625,7 +630,7 @@ void shouldReplaceAllNamedExpressionParametersWithInClause() {
 	@Test // DATAJPA-712
 	void shouldReplaceExpressionWithLikeParameters() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"select a from A a where a.b LIKE :#{#filter.login}% and a.c LIKE %:#{#filter.login}", true);
 		String queryString = query.getQueryString();
 
@@ -636,8 +641,8 @@ void shouldReplaceExpressionWithLikeParameters() {
 	@Test // DATAJPA-712, GH-3619
 	void shouldReplaceAllPositionExpressionParametersWithInClause() {
 
-		StringQuery query = new StringQuery("select a from A a where a.b in ?#{#bs} and a.c in ?#{#cs} and a.d in ?${foo}",
-				true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"select a from A a where a.b in ?#{#bs} and a.c in ?#{#cs} and a.d in ?${foo}", true);
 		String queryString = query.getQueryString();
 
 		assertThat(queryString).isEqualTo("select a from A a where a.b in ?1 and a.c in ?2 and a.d in ?3");
@@ -653,12 +658,11 @@ void shouldReplaceAllPositionExpressionParametersWithInClause() {
 	@Test // DATAJPA-864
 	void detectsConstructorExpressions() {
 
-		assertThat(
-				new StringQuery("select  new  com.example.Dto(a.foo, a.bar)  from A a", false).hasConstructorExpression())
-				.isTrue();
-		assertThat(new StringQuery("select new com.example.Dto (a.foo, a.bar) from A a", false).hasConstructorExpression())
-				.isTrue();
-		assertThat(new StringQuery("select a from A a", true).hasConstructorExpression()).isFalse();
+		assertThat(new TestEntityQuery("select  new  com.example.Dto(a.foo, a.bar)  from A a", false)
+				.hasConstructorExpression()).isTrue();
+		assertThat(new TestEntityQuery("select new com.example.Dto (a.foo, a.bar) from A a", false)
+				.hasConstructorExpression()).isTrue();
+		assertThat(new TestEntityQuery("select a from A a", true).hasConstructorExpression()).isFalse();
 	}
 
 	/**
@@ -669,14 +673,16 @@ void detectsConstructorExpressions() {
 	void detectsConstructorExpressionForDefaultConstructor() {
 
 		// Parentheses required
-		assertThat(new StringQuery("select new com.example.Dto(a.name) from A a", false).hasConstructorExpression())
+		assertThat(
+				new TestEntityQuery("select new com.example.Dto(a.name) from A a", false).hasConstructorExpression())
 				.isTrue();
 	}
 
 	@Test // DATAJPA-1179
 	void bindingsMatchQueryForIdenticalSpelExpressions() {
 
-		StringQuery query = new StringQuery("select a from A a where a.first = :#{#exp} or a.second = :#{#exp}", true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"select a from A a where a.first = :#{#exp} or a.second = :#{#exp}", true);
 
 		List<ParameterBinding> bindings = query.getParameterBindings();
 		assertThat(bindings).isNotEmpty();
@@ -703,7 +709,7 @@ void getProjection() {
 
 	void checkProjection(String query, String expected, String description, boolean nativeQuery) {
 
-		assertThat(new StringQuery(query, nativeQuery).getProjection()) //
+		assertThat(new TestEntityQuery(query, nativeQuery).getProjection()) //
 				.as("%s (%s)", description, query) //
 				.isEqualTo(expected);
 	}
@@ -727,7 +733,7 @@ void getAlias() {
 
 	private void checkAlias(String query, String expected, String description, boolean nativeQuery) {
 
-		assertThat(new StringQuery(query, nativeQuery).getAlias()) //
+		assertThat(new TestEntityQuery(query, nativeQuery).getAlias()) //
 				.as("%s (%s)", description, query) //
 				.isEqualTo(expected);
 	}
@@ -735,32 +741,32 @@ private void checkAlias(String query, String expected, String description, boole
 	@Test // DATAJPA-1200
 	void testHasNamedParameter() {
 
-		checkHasNamedParameter("select something from x where id = :id", true, "named parameter", true);
-		checkHasNamedParameter("in the :id middle", true, "middle", false);
-		checkHasNamedParameter(":id start", true, "beginning", false);
-		checkHasNamedParameter(":id", true, "alone", false);
-		checkHasNamedParameter("select something from x where id = :id", true, "named parameter", true);
-		checkHasNamedParameter(":UPPERCASE", true, "uppercase", false);
-		checkHasNamedParameter(":lowercase", true, "lowercase", false);
-		checkHasNamedParameter(":2something", true, "beginning digit", false);
-		checkHasNamedParameter(":2", true, "only digit", false);
-		checkHasNamedParameter(":.something", true, "dot", false);
-		checkHasNamedParameter(":_something", true, "underscore", false);
-		checkHasNamedParameter(":$something", true, "dollar", false);
-		checkHasNamedParameter(":\uFE0F", true, "non basic latin emoji", false); //
-		checkHasNamedParameter(":\u4E01", true, "chinese japanese korean", false);
-
-		checkHasNamedParameter("no bind variable", false, "no bind variable", false);
-		checkHasNamedParameter(":\u2004whitespace", false, "non basic latin whitespace", false);
-		checkHasNamedParameter("select something from x where id = ?1", false, "indexed parameter", true);
-		checkHasNamedParameter("::", false, "double colon", false);
-		checkHasNamedParameter(":", false, "end of query", false);
-		checkHasNamedParameter(":\u0003", false, "non-printable", false);
-		checkHasNamedParameter(":*", false, "basic latin emoji", false);
-		checkHasNamedParameter("\\:", false, "escaped colon", false);
-		checkHasNamedParameter("::id", false, "double colon with identifier", false);
-		checkHasNamedParameter("\\:id", false, "escaped colon with identifier", false);
-		checkHasNamedParameter("select something from x where id = #something", false, "hash", true);
+		checkHasNamedParameter("select something from x where id = :id", true, "named parameter");
+		checkHasNamedParameter("in the :id middle", true, "middle");
+		checkHasNamedParameter(":id start", true, "beginning");
+		checkHasNamedParameter(":id", true, "alone");
+		checkHasNamedParameter("select something from x where id = :id", true, "named parameter");
+		checkHasNamedParameter(":UPPERCASE", true, "uppercase");
+		checkHasNamedParameter(":lowercase", true, "lowercase");
+		checkHasNamedParameter(":2something", true, "beginning digit");
+		checkHasNamedParameter(":2", true, "only digit");
+		checkHasNamedParameter(":.something", true, "dot");
+		checkHasNamedParameter(":_something", true, "underscore");
+		checkHasNamedParameter(":$something", true, "dollar");
+		checkHasNamedParameter(":\uFE0F", true, "non basic latin emoji"); //
+		checkHasNamedParameter(":\u4E01", true, "chinese japanese korean");
+
+		checkHasNamedParameter("no bind variable", false, "no bind variable");
+		checkHasNamedParameter(":\u2004whitespace", false, "non basic latin whitespace");
+		checkHasNamedParameter("select something from x where id = ?1", false, "indexed parameter");
+		checkHasNamedParameter("::", false, "double colon");
+		checkHasNamedParameter(":", false, "end of query");
+		checkHasNamedParameter(":\u0003", false, "non-printable");
+		checkHasNamedParameter(":*", false, "basic latin emoji");
+		checkHasNamedParameter("\\:", false, "escaped colon");
+		checkHasNamedParameter("::id", false, "double colon with identifier");
+		checkHasNamedParameter("\\:id", false, "escaped colon with identifier");
+		checkHasNamedParameter("select something from x where id = #something", false, "hash");
 	}
 
 	@Test // DATAJPA-1235
@@ -780,7 +786,7 @@ void ignoresQuotedNamedParameterLookAlike() {
 	void detectsMultiplePositionalParameterBindingsWithoutIndex() {
 
 		String queryString = "select u from User u where u.id in ? and u.names in ? and foo = ?";
-		StringQuery query = new StringQuery(queryString, false);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, false);
 
 		assertThat(query.getQueryString()).isEqualTo(queryString);
 		assertThat(query.hasParameterBindings()).isTrue();
@@ -800,16 +806,18 @@ void failOnMixedBindingsWithoutIndex() {
 		for (String testQuery : testQueries) {
 
 			Assertions.assertThatExceptionOfType(IllegalArgumentException.class) //
-					.describedAs(testQuery).isThrownBy(() -> new StringQuery(testQuery, false));
+					.describedAs(testQuery).isThrownBy(() -> new TestEntityQuery(testQuery, false));
 		}
 	}
 
 	@Test // DATAJPA-1307
 	void makesUsageOfJdbcStyleParameterAvailable() {
 
-		assertThat(new StringQuery("from Something something where something = ?", false).usesJdbcStyleParameters())
+		assertThat(
+				new TestEntityQuery("from Something something where something = ?", false).usesJdbcStyleParameters())
 				.isTrue();
-		assertThat(new StringQuery("from Something something where something =?", false).usesJdbcStyleParameters())
+		assertThat(
+				new TestEntityQuery("from Something something where something =?", false).usesJdbcStyleParameters())
 				.isTrue();
 
 		List<String> testQueries = Arrays.asList( //
@@ -820,7 +828,7 @@ void makesUsageOfJdbcStyleParameterAvailable() {
 
 		for (String testQuery : testQueries) {
 
-			assertThat(new StringQuery(testQuery, false) //
+			assertThat(new TestEntityQuery(testQuery, false) //
 					.usesJdbcStyleParameters()) //
 					.describedAs(testQuery) //
 					.describedAs(testQuery) //
@@ -832,7 +840,7 @@ void makesUsageOfJdbcStyleParameterAvailable() {
 	void questionMarkInStringLiteral() {
 
 		String queryString = "select '? ' from dual";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(query.getQueryString()).isEqualTo(queryString);
 		assertThat(query.hasParameterBindings()).isFalse();
@@ -852,7 +860,7 @@ void isNotDefaultProjection() {
 				"select a, b from C");
 
 		for (String queryString : queriesWithoutDefaultProjection) {
-			assertThat(new StringQuery(queryString, true).isDefaultProjection()) //
+			assertThat(new TestEntityQuery(queryString, true).isDefaultProjection()) //
 					.describedAs(queryString) //
 					.isFalse();
 		}
@@ -869,7 +877,7 @@ void isNotDefaultProjection() {
 		);
 
 		for (String queryString : queriesWithDefaultProjection) {
-			assertThat(new StringQuery(queryString, true).isDefaultProjection()) //
+			assertThat(new TestEntityQuery(queryString, true).isDefaultProjection()) //
 					.describedAs(queryString) //
 					.isTrue();
 		}
@@ -879,7 +887,7 @@ void isNotDefaultProjection() {
 	void questionMarkInStringLiteralWithParameters() {
 
 		String queryString = "SELECT CAST(REGEXP_SUBSTR(itp.template_as_txt, '(?<=templateId\\\\\\\\=)(\\\\\\\\d+)(?:\\\\\\\\R)') AS INT) AS templateId FROM foo itp WHERE bar = ?1 AND baz = 1";
-		StringQuery query = new StringQuery(queryString, false);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, false);
 
 		assertThat(query.getQueryString()).isEqualTo(queryString);
 		assertThat(query.hasParameterBindings()).isTrue();
@@ -891,7 +899,7 @@ void questionMarkInStringLiteralWithParameters() {
 	void usingPipesWithNamedParameter() {
 
 		String queryString = "SELECT u FROM User u WHERE u.lastname LIKE '%'||:name||'%'";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(query.getParameterBindings()) //
 				.extracting(ParameterBinding::getName) //
@@ -902,7 +910,7 @@ void usingPipesWithNamedParameter() {
 	void usingGreaterThanWithNamedParameter() {
 
 		String queryString = "SELECT u FROM User u WHERE :age>u.age";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(query.getParameterBindings()) //
 				.extracting(ParameterBinding::getName) //
@@ -911,23 +919,24 @@ void usingGreaterThanWithNamedParameter() {
 
 	void checkNumberOfNamedParameters(String query, int expectedSize, String label, boolean nativeQuery) {
 
-		DeclaredQuery declaredQuery = DeclaredQuery.of(query, nativeQuery);
+		DeclaredQuery declaredQuery = nativeQuery ? DeclaredQuery.nativeQuery(query) : DeclaredQuery.jpqlQuery(query);
+		EntityQuery introspectedQuery = EntityQuery.create(declaredQuery, QueryEnhancerSelector.DEFAULT_SELECTOR);
 
-		assertThat(declaredQuery.hasNamedParameter()) //
+		assertThat(introspectedQuery.hasNamedParameter()) //
 				.describedAs("hasNamed Parameter " + label) //
 				.isEqualTo(expectedSize > 0);
-		assertThat(declaredQuery.getParameterBindings()) //
+		assertThat(introspectedQuery.getParameterBindings()) //
 				.describedAs("parameterBindings " + label) //
 				.hasSize(expectedSize);
 	}
 
-	private void checkHasNamedParameter(String query, boolean expected, String label, boolean nativeQuery) {
+	private void checkHasNamedParameter(String query, boolean expected, String label) {
 
-		List<ParameterBinding> bindings = new ArrayList<>();
-		StringQuery.ParameterBindingParser.INSTANCE.parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(query,
-				bindings, new StringQuery.Metadata());
+		DeclaredQuery source = DeclaredQuery.jpqlQuery(query);
+		PreprocessedQuery bindableQuery = PreprocessedQuery.ParameterBindingParser.INSTANCE.parse(query,
+				source::rewrite, it -> {});
 
-		assertThat(bindings.stream().anyMatch(it -> it.getIdentifier().hasName())) //
+		assertThat(bindableQuery.getBindings().stream().anyMatch(it -> it.getIdentifier().hasName())) //
 				.describedAs(String.format("<%s> (%s)", query, label)) //
 				.isEqualTo(expected);
 	}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancerUnitTests.java
index 6b9c4e2478..7dd6dd757c 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancerUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancerUnitTests.java
@@ -21,6 +21,8 @@
 import org.junit.jupiter.api.Test;
 
 import org.springframework.data.domain.Sort;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.query.ReturnedType;
 
 /**
  * TCK Tests for {@link DefaultQueryEnhancer}.
@@ -31,8 +33,8 @@
 class DefaultQueryEnhancerUnitTests extends QueryEnhancerTckTests {
 
 	@Override
-	QueryEnhancer createQueryEnhancer(DeclaredQuery declaredQuery) {
-		return new DefaultQueryEnhancer(declaredQuery);
+	QueryEnhancer createQueryEnhancer(DeclaredQuery query) {
+		return new DefaultQueryEnhancer(query);
 	}
 
 	@Override
@@ -43,9 +45,10 @@ void shouldDeriveNativeCountQueryWithVariable(String query, String expected) {}
 	@Test // GH-3546
 	void shouldApplySorting() {
 
-		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.of("SELECT e FROM Employee e", true));
+		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery("SELECT e FROM Employee e"));
 
-		String sql = enhancer.applySorting(Sort.by("foo", "bar"));
+		String sql = enhancer.rewrite(new DefaultQueryRewriteInformation(Sort.by("foo", "bar"),
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())));
 
 		assertThat(sql).isEqualTo("SELECT e FROM Employee e order by e.foo asc, e.bar asc");
 	}
@@ -53,9 +56,11 @@ void shouldApplySorting() {
 	@Test // GH-3811
 	void shouldApplySortingWithNullHandling() {
 
-		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.of("SELECT e FROM Employee e", true));
+		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery("SELECT e FROM Employee e"));
 
-		String sql = enhancer.applySorting(Sort.by(Sort.Order.asc("foo").nullsFirst(), Sort.Order.asc("bar").nullsLast()));
+		String sql = enhancer.rewrite(new DefaultQueryRewriteInformation(
+				Sort.by(Sort.Order.asc("foo").nullsFirst(), Sort.Order.asc("bar").nullsLast()),
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())));
 
 		assertThat(sql).isEqualTo("SELECT e FROM Employee e order by e.foo asc nulls first, e.bar asc nulls last");
 	}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlComplianceTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlComplianceTests.java
index 2ec5f229a1..9b092c7924 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlComplianceTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlComplianceTests.java
@@ -18,15 +18,17 @@
 import static org.assertj.core.api.Assertions.*;
 
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import org.springframework.data.jpa.repository.query.QueryRenderer.TokenRenderer;
 
 /**
  * Tests built around examples of EQL found in the EclipseLink's docs at
  * https://wiki.eclipse.org/EclipseLink/UserGuide/JPA/Basic_JPA_Development/Querying/JPQL<br/>
- * With the exception of {@literal MOD} which is defined as {@literal MOD(arithmetic_expression , arithmetic_expression)},
- * but shown in tests as {@literal MOD(arithmetic_expression ? arithmetic_expression)}.
- * <br/>
+ * With the exception of {@literal MOD} which is defined as
+ * {@literal MOD(arithmetic_expression , arithmetic_expression)}, but shown in tests as
+ * {@literal MOD(arithmetic_expression ? arithmetic_expression)}. <br/>
  * IMPORTANT: Purely verifies the parser without any transformations.
  *
  * @author Greg Turnquist
@@ -96,6 +98,7 @@ void joinFetch() {
 
 		assertQuery("SELECT e FROM Employee e JOIN FETCH e.address");
 		assertQuery("SELECT e FROM Employee e JOIN FETCH e.address a ORDER BY a.city");
+		assertQuery("SELECT e FROM Employee e JOIN FETCH e.address AS a ORDER BY a.city");
 	}
 
 	@Test
@@ -116,6 +119,21 @@ void subselectsInFromClause() {
 				"SELECT e, c.city FROM Employee e, (SELECT DISTINCT a.city FROM Address a) c WHERE e.address.city = c.city");
 	}
 
+	@Test // GH-3277
+	void numericLiterals() {
+
+		assertQuery("SELECT e FROM Employee e WHERE e.id = 1234");
+		assertQuery("SELECT e FROM Employee e WHERE e.id = 1234L");
+		assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14");
+		assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14F");
+		assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14e32D");
+	}
+
+	@Test // GH-3308
+	void newWithStrings() {
+		assertQuery("select new com.example.demo.SampleObject(se.id, se.sampleValue, \"java\") from SampleEntity se");
+	}
+
 	@Test
 	void orderByClause() {
 
@@ -412,4 +430,53 @@ void isNullAndIsNotNull() {
 		assertQuery("SELECT e FROM Employee e WHERE (e.active IS NOT null OR e.active = true)");
 		assertQuery("SELECT e FROM Employee e WHERE (e.active IS NOT NULL OR e.active = true)");
 	}
+
+	@Test // GH-3496
+	void lateralShouldBeAValidParameter() {
+
+		assertQuery("select e from Employee e where e.lateral = :_lateral");
+		assertQuery("select te from TestEntity te where te.lateral = :lateral");
+	}
+
+	@Test // GH-3136
+	void intersect() {
+
+		assertQuery("""
+				SELECT e FROM Employee e JOIN e.phones p WHERE p.areaCode = :areaCode1
+				INTERSECT SELECT e FROM Employee e JOIN e.phones p WHERE p.areaCode = :areaCode2
+				""");
+	}
+
+	@Test // GH-3136
+	void except() {
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				EXCEPT SELECT e FROM Employee e WHERE e.salary > e.manager.salary
+				""");
+	}
+
+	@ParameterizedTest // GH-3136
+	@ValueSource(strings = { "STRING", "INTEGER", "FLOAT", "DOUBLE" })
+	void cast(String targetType) {
+		assertQuery("SELECT CAST(e.salary AS %s) FROM Employee e".formatted(targetType));
+	}
+
+	@ParameterizedTest // GH-3136
+	@ValueSource(strings = { "LEFT", "RIGHT" })
+	void leftRightStringFunctions(String keyword) {
+		assertQuery("SELECT %s(e.name, 3) FROM Employee e".formatted(keyword));
+	}
+
+	@Test // GH-3136
+	void replaceStringFunctions() {
+		assertQuery("SELECT REPLACE(e.name, 'o', 'a') FROM Employee e");
+		assertQuery("SELECT REPLACE(e.name, ' ', '_') FROM Employee e");
+	}
+
+	@Test // GH-3136
+	void stringConcatWithPipes() {
+		assertQuery("SELECT e.firstname || e.lastname AS name FROM Employee e");
+	}
+
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java
index a2f1a125fb..b8ac4f35a3 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java
@@ -63,11 +63,11 @@ void shouldRewriteQueriesWithSubselect() {
 	void shouldNotTranslateConstructorExpressionQuery() {
 
 		JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser
-				.parseQuery("SELECT NEW String(p) from Person p");
+				.parseQuery("SELECT NEW Foo(p) from Person p");
 
 		QueryTokenStream visit = getTransformer(parser).visit(parser.getContext());
 
-		assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW String(p) from Person p");
+		assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW Foo(p) from Person p");
 	}
 
 	@Test
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlParserQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlParserQueryEnhancerUnitTests.java
index 8895fc4c19..dbe4d45a9f 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlParserQueryEnhancerUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlParserQueryEnhancerUnitTests.java
@@ -30,9 +30,9 @@ public class EqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests {
 	@Override
 	QueryEnhancer createQueryEnhancer(DeclaredQuery query) {
 
-		assumeThat(query.isNativeQuery()).isFalse();
+		assumeThat(query.isNative()).isFalse();
 
-		return JpaQueryEnhancer.forEql(query);
+		return JpaQueryEnhancer.forEql(query.getQueryString());
 	}
 
 	@Override
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlQueryRendererTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlQueryRendererTests.java
index 72bdfc3b1b..6ff1b23387 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlQueryRendererTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlQueryRendererTests.java
@@ -338,6 +338,38 @@ OR TREAT(e AS Contractor).hours > 100
 				""");
 	}
 
+	@Test // GH-3136
+	void substring() {
+
+		assertQuery("select substring(c.number, 1, 2) " + //
+				"from Call c");
+
+		assertQuery("select substring(c.number, 1) " + //
+				"from Call c");
+	}
+
+	@Test // GH-3136
+	void currentDateFunctions() {
+
+		assertQuery("select CURRENT_DATE " + //
+				"from Call c ");
+
+		assertQuery("select CURRENT_TIME " + //
+				"from Call c ");
+
+		assertQuery("select CURRENT_TIMESTAMP " + //
+				"from Call c ");
+
+		assertQuery("select LOCAL_DATE " + //
+				"from Call c ");
+
+		assertQuery("select LOCAL_TIME " + //
+				"from Call c ");
+
+		assertQuery("select LOCAL_DATETIME " + //
+				"from Call c ");
+	}
+
 	@Test
 	void pathExpressionsNamedParametersExample() {
 
@@ -449,18 +481,13 @@ AND INDEX(w) = 0
 	 * @see #functionInvocationExampleWithCorrection()
 	 */
 	@Test
-	@Disabled(SPEC_FAULT + "FUNCTION calls needs a comparator")
-	void functionInvocationExample_SPEC_BUG() {
+	void functionInvocationExample() {
 
 		assertQuery("""
 				SELECT c
 				FROM Customer c
 				WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit)
 				""");
-	}
-
-	@Test
-	void functionInvocationExampleWithCorrection() {
 
 		assertQuery("""
 				SELECT c
@@ -597,6 +624,14 @@ SELECT c.country, COUNT(c)
 				GROUP BY c.country
 				HAVING COUNT(c) > 30
 				""");
+
+		assertQuery("""
+				SELECT COUNT(f)
+				FROM FooEntity f
+				WHERE f.name IN ('Y', 'Basic', 'Remit')
+							AND f.size = 10
+				HAVING COUNT(f) > 0
+				""");
 	}
 
 	@Test
@@ -1024,6 +1059,59 @@ void powerShouldBeLegalInAQuery() {
 		assertQuery("select e.power.id from MyEntity e");
 	}
 
+	@Test // GH-3136
+	void doublePipeShouldBeValidAsAStringConcatOperator() {
+
+		assertQuery("""
+				select e.name || ' ' || e.title
+				from Employee e
+				""");
+	}
+
+	@Test // GH-3136
+	void combinedSelectStatementsShouldWork() {
+
+		assertQuery("""
+				select e from Employee e where e.last_name = 'Baggins'
+				intersect
+				select e from Employee e where e.first_name = 'Samwise'
+				union
+				select e from Employee e where e.home = 'The Shire'
+				except
+				select e from Employee e where e.home = 'Isengard'
+				""");
+	}
+
+	@Disabled
+	@Test // GH-3136
+	void additionalStringOperationsShouldWork() {
+
+		assertQuery("""
+				select
+					replace(e.name, 'Baggins', 'Proudfeet'),
+					left(e.role, 4),
+					right(e.home, 5),
+					cast(e.distance_from_home, int)
+				from Employee e
+				""");
+	}
+
+	@Test // GH-3136
+	void orderByWithNullsFirstOrLastShouldWork() {
+
+		assertQuery("""
+				select a
+				from Element a
+				order by mutationAm desc nulls first
+				""");
+
+		assertQuery("""
+				select a
+				from Element a
+				order by mutationAm desc nulls last
+				""");
+	}
+
 	@ParameterizedTest // GH-3342
 	@ValueSource(strings = { "select 1 from User u", "select -1 from User u", "select +1 from User u",
 			"select +1 * -100 from User u", "select count(u) * -0.7f from User u",
@@ -1064,4 +1152,5 @@ void reservedWordsShouldWork() {
 		assertQuery("select f from FooEntity f where upper(f.name) IN :names");
 		assertQuery("select f from FooEntity f where f.size IN :sizes");
 	}
+
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlQueryTransformerTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlQueryTransformerTests.java
index 3c1fec2ed3..8f93859699 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlQueryTransformerTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlQueryTransformerTests.java
@@ -20,6 +20,7 @@
 import java.util.stream.Stream;
 
 import org.assertj.core.api.SoftAssertions;
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -28,7 +29,8 @@
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Sort.Order;
 import org.springframework.data.jpa.domain.JpaSort;
-import org.springframework.lang.Nullable;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.query.ReturnedType;
 
 /**
  * Verify that EQL queries are properly transformed through the {@link JpaQueryEnhancer} and the
@@ -221,7 +223,9 @@ void applySortingAccountsForNewlinesInSubselect() {
 				where exists (select u2
 				from user u2
 				)
-				""").applySorting(sort)).isEqualToIgnoringWhitespace("""
+				""").rewrite(new DefaultQueryRewriteInformation(sort,
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()))))
+				.isEqualToIgnoringWhitespace("""
 				select u
 				from user u
 				where exists (select u2
@@ -803,7 +807,8 @@ private void assertCountQuery(String originalQuery, String countQuery) {
 	}
 
 	private String createQueryFor(String query, Sort sort) {
-		return newParser(query).applySorting(sort);
+		return newParser(query).rewrite(new DefaultQueryRewriteInformation(sort,
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())));
 	}
 
 	private String createCountQueryFor(String query) {
@@ -827,6 +832,6 @@ private String projection(String query) {
 	}
 
 	private QueryEnhancer newParser(String query) {
-		return JpaQueryEnhancer.forEql(DeclaredQuery.of(query, false));
+		return JpaQueryEnhancer.forEql(query);
 	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlSpecificationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlSpecificationTests.java
deleted file mode 100644
index bff45ec75d..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlSpecificationTests.java
+++ /dev/null
@@ -1,905 +0,0 @@
-/*
- * Copyright 2023-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.springframework.data.jpa.repository.query;
-
-import static org.assertj.core.api.Assertions.*;
-
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.data.jpa.repository.query.QueryRenderer.TokenRenderer;
-
-/**
- * Tests built around examples of EQL found in the JPA spec
- * https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc<br/>
- * <br/>
- * IMPORTANT: Purely verifies the parser without any transformations.
- *
- * @author Greg Turnquist
- */
-class EqlSpecificationTests {
-
-	private static final String SPEC_FAULT = "Disabled due to spec fault> ";
-
-	private static String parseWithoutChanges(String query) {
-
-		JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser.parseQuery(query);
-
-		return TokenRenderer.render(new EqlQueryRenderer().visit(parser.getContext()));
-	}
-
-	private void assertQuery(String query) {
-
-		String slimmedDownQuery = reduceWhitespace(query);
-		assertThat(parseWithoutChanges(slimmedDownQuery)).isEqualTo(slimmedDownQuery);
-	}
-
-	private String reduceWhitespace(String original) {
-
-		return original //
-				.replaceAll("[ \\t\\n]{1,}", " ") //
-				.trim();
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
-	 */
-	@Test
-	void joinExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order AS o JOIN o.lineItems AS l
-				WHERE l.shipped = FALSE
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#identification-variables
-	 */
-	@Test
-	void joinExample2() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l JOIN l.product p
-				WHERE p.productType = 'office_supplies'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#range-variable-declarations
-	 */
-	@Test
-	void rangeVariableDeclarations() {
-
-		assertQuery("""
-				SELECT DISTINCT o1
-				FROM Order o1, Order o2
-				WHERE o1.quantity > o2.quantity AND
-				 o2.customer.lastname = 'Smith' AND
-				 o2.customer.firstname = 'John'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample1() {
-
-		assertQuery("""
-				SELECT i.name, VALUE(p)
-				FROM Item i JOIN i.photos p
-				WHERE KEY(p) LIKE '%egret'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample2() {
-
-		assertQuery("""
-				SELECT i.name, p
-				FROM Item i JOIN i.photos p
-				WHERE KEY(p) LIKE '%egret'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample3() {
-
-		assertQuery("""
-				SELECT p.vendor
-				FROM Employee e JOIN e.contactInfo.phones p
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample4() {
-
-		assertQuery("""
-				SELECT p.vendor
-				FROM Employee e JOIN e.contactInfo c JOIN c.phones p
-				WHERE e.contactInfo.address.zipcode = '95054'
-				""");
-	}
-
-	@Test
-	void pathExpressionSyntaxExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT l.product
-				FROM Order AS o JOIN o.lineItems l
-				""");
-	}
-
-	@Test
-	void joinsExample1() {
-
-		assertQuery("""
-				SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize
-				""");
-	}
-
-	@Test
-	void joinsExample2() {
-
-		assertQuery("""
-				SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1
-				""");
-	}
-
-	@Test
-	void joinsInnerExample() {
-
-		assertQuery("""
-				SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1
-				""");
-	}
-
-	@Test
-	void joinsInExample() {
-
-		assertQuery("""
-				SELECT OBJECT(c) FROM Customer c, IN(c.orders) o WHERE c.status = 1
-				""");
-	}
-
-	@Test
-	void doubleJoinExample() {
-
-		assertQuery("""
-				SELECT p.vendor
-				FROM Employee e JOIN e.contactInfo c JOIN c.phones p
-				WHERE c.address.zipcode = '95054'
-				""");
-	}
-
-	@Test
-	void leftJoinExample() {
-
-		assertQuery("""
-				SELECT s.name, COUNT(p)
-				FROM Suppliers s LEFT JOIN s.products p
-				GROUP BY s.name
-				""");
-	}
-
-	@Test
-	void leftJoinOnExample() {
-
-		assertQuery("""
-				SELECT s.name, COUNT(p)
-				FROM Suppliers s LEFT JOIN s.products p
-				    ON p.status = 'inStock'
-				GROUP BY s.name
-				""");
-	}
-
-	@Test
-	void leftJoinWhereExample() {
-
-		assertQuery("""
-				SELECT s.name, COUNT(p)
-				FROM Suppliers s LEFT JOIN s.products p
-				WHERE p.status = 'inStock'
-				GROUP BY s.name
-				""");
-	}
-
-	@Test
-	void leftJoinFetchExample() {
-
-		assertQuery("""
-				SELECT d
-				FROM Department d LEFT JOIN FETCH d.employees
-				WHERE d.deptno = 1
-				""");
-	}
-
-	@Test
-	void collectionMemberExample() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				WHERE l.product.productType = 'office_supplies'
-				""");
-	}
-
-	@Test
-	void collectionMemberInExample() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o, IN(o.lineItems) l
-				WHERE l.product.productType = 'office_supplies'
-				""");
-	}
-
-	@Test
-	void fromClauseExample() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order AS o JOIN o.lineItems l JOIN l.product p
-				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample1() {
-
-		assertQuery("""
-				SELECT b.name, b.ISBN
-				FROM Order o JOIN TREAT(o.product AS Book) b
-				    """);
-	}
-
-	@Test
-	void fromClauseDowncastingExample2() {
-
-		assertQuery("""
-				SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp
-				WHERE lp.budget > 1000
-				    """);
-	}
-
-	/**
-	 * @see #fromClauseDowncastingExample3fixed()
-	 */
-	@Test
-	@Disabled(SPEC_FAULT + "Use double-quotes when it should be using single-quotes for a string literal")
-	void fromClauseDowncastingExample3_SPEC_BUG() {
-
-		assertQuery("""
-				SELECT e FROM Employee e JOIN e.projects p
-				WHERE TREAT(p AS LargeProject).budget > 1000
-				    OR TREAT(p AS SmallProject).name LIKE 'Persist%'
-				    OR p.description LIKE "cost overrun"
-				    """);
-	}
-
-	@Test
-	void fromClauseDowncastingExample3fixed() {
-
-		assertQuery("""
-				SELECT e FROM Employee e JOIN e.projects p
-				WHERE TREAT(p AS LargeProject).budget > 1000
-				    OR TREAT(p AS SmallProject).name LIKE 'Persist%'
-				    OR p.description LIKE 'cost overrun'
-				    """);
-	}
-
-	@Test
-	void fromClauseDowncastingExample4() {
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE TREAT(e AS Exempt).vacationDays > 10
-				    OR TREAT(e AS Contractor).hours > 100
-				    """);
-	}
-
-	@Test
-	void pathExpressionsNamedParametersExample() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.status = :stat
-				""");
-	}
-
-	@Test
-	void betweenExpressionsExample() {
-
-		assertQuery("""
-				SELECT t
-				FROM CreditCard c JOIN c.transactionHistory t
-				WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9
-				""");
-	}
-
-	@Test
-	void isEmptyExample() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS EMPTY
-				""");
-	}
-
-	@Test
-	void memberOfExample() {
-
-		assertQuery("""
-				SELECT p
-				FROM Person p
-				WHERE 'Joe' MEMBER OF p.nicknames
-				""");
-	}
-
-	@Test
-	void existsSubSelectExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE EXISTS (SELECT spouseEmp
-				    FROM Employee spouseEmp
-				        WHERE spouseEmp = emp.spouse)
-				""");
-	}
-
-	@Test
-	void allExample() {
-
-		assertQuery("""
-				SELECT emp
-				FROM Employee emp
-				WHERE emp.salary > ALL (SELECT m.salary
-				    FROM Manager m
-				    WHERE m.department = emp.department)
-				    """);
-	}
-
-	@Test
-	void existsSubSelectExample2() {
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE EXISTS (SELECT spouseEmp
-				    FROM Employee spouseEmp
-				    WHERE spouseEmp = emp.spouse)
-				    """);
-	}
-
-	@Test
-	void subselectNumericComparisonExample1() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE (SELECT AVG(o.price) FROM c.orders o) > 100
-				""");
-	}
-
-	@Test
-	void subselectNumericComparisonExample2() {
-
-		assertQuery("""
-				SELECT goodCustomer
-				FROM Customer goodCustomer
-				WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed) / 2.0 FROM Customer c)
-				""");
-	}
-
-	@Test
-	void indexExample() {
-
-		assertQuery("""
-				SELECT w.name
-				FROM Course c JOIN c.studentWaitlist w
-				WHERE c.name = 'Calculus'
-				AND INDEX(w) = 0
-				""");
-	}
-
-	/**
-	 * @see #functionInvocationExampleWithCorrection()
-	 */
-	@Test
-	@Disabled(SPEC_FAULT + "FUNCTION calls needs a comparator")
-	void functionInvocationExample_SPEC_BUG() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit)
-				""");
-	}
-
-	@Test
-	void functionInvocationExampleWithCorrection() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE
-				""");
-	}
-
-	@Test
-	void updateCaseExample1() {
-
-		assertQuery("""
-				UPDATE Employee e
-				SET e.salary =
-				    CASE WHEN e.rating = 1 THEN e.salary * 1.1
-				         WHEN e.rating = 2 THEN e.salary * 1.05
-				         ELSE e.salary * 1.01
-				    END
-				    """);
-	}
-
-	@Test
-	void updateCaseExample2() {
-
-		assertQuery("""
-				UPDATE Employee e
-				SET e.salary =
-				    CASE e.rating WHEN 1 THEN e.salary * 1.1
-				                  WHEN 2 THEN e.salary * 1.05
-				                  ELSE e.salary * 1.01
-				    END
-				    """);
-	}
-
-	@Test
-	void selectCaseExample1() {
-
-		assertQuery("""
-				SELECT e.name,
-				    CASE TYPE(e) WHEN Exempt THEN 'Exempt'
-				                 WHEN Contractor THEN 'Contractor'
-				                 WHEN Intern THEN 'Intern'
-				                 ELSE 'NonExempt'
-				    END
-				FROM Employee e
-				WHERE e.dept.name = 'Engineering'
-				""");
-	}
-
-	@Test
-	void selectCaseExample2() {
-
-		assertQuery("""
-				SELECT e.name,
-				       f.name,
-				       CONCAT(CASE WHEN f.annualMiles > 50000 THEN 'Platinum '
-				                   WHEN f.annualMiles > 25000 THEN 'Gold '
-				                   ELSE ''
-				              END,
-				       'Frequent Flyer')
-				FROM Employee e JOIN e.frequentFlierPlan f
-				""");
-	}
-
-	@Test
-	void theRest() {
-
-		assertQuery("""
-				SELECT e
-				 FROM Employee e
-				 WHERE TYPE(e) IN (Exempt, Contractor)
-				 """);
-	}
-
-	@Test
-	void theRest2() {
-
-		assertQuery("""
-				SELECT e
-				    FROM Employee e
-				    WHERE TYPE(e) IN (:empType1, :empType2)
-				""");
-	}
-
-	@Test
-	void theRest3() {
-
-		assertQuery("""
-				SELECT e
-				FROM Employee e
-				WHERE TYPE(e) IN :empTypes
-				""");
-	}
-
-	@Test
-	void theRest4() {
-
-		assertQuery("""
-				SELECT TYPE(e)
-				FROM Employee e
-				WHERE TYPE(e) <> Exempt
-				""");
-	}
-
-	@Test
-	void theRest5() {
-
-		assertQuery("""
-				SELECT c.status, AVG(c.filledOrderCount), COUNT(c)
-				FROM Customer c
-				GROUP BY c.status
-				HAVING c.status IN (1, 2)
-				""");
-	}
-
-	@Test
-	void theRest6() {
-
-		assertQuery("""
-				SELECT c.country, COUNT(c)
-				FROM Customer c
-				GROUP BY c.country
-				HAVING COUNT(c) > 30
-				""");
-	}
-
-	@Test
-	void theRest7() {
-
-		assertQuery("""
-				SELECT c, COUNT(o)
-				FROM Customer c JOIN c.orders o
-				GROUP BY c
-				HAVING COUNT(o) >= 5
-				""");
-	}
-
-	@Test
-	void theRest8() {
-
-		assertQuery("""
-				SELECT c.id, c.status
-				FROM Customer c JOIN c.orders o
-				WHERE o.count > 100
-				""");
-	}
-
-	@Test
-	void theRest9() {
-
-		assertQuery("""
-				SELECT v.location.street, KEY(i).title, VALUE(i)
-				FROM VideoStore v JOIN v.videoInventory i
-				WHERE v.location.zipcode = '94301' AND VALUE(i) > 0
-				""");
-	}
-
-	@Test
-	void theRest10() {
-
-		assertQuery("""
-				SELECT o.lineItems FROM Order AS o
-				""");
-	}
-
-	@Test
-	void theRest11() {
-
-		assertQuery("""
-				SELECT c, COUNT(l) AS itemCount
-				FROM Customer c JOIN c.Orders o JOIN o.lineItems l
-				WHERE c.address.state = 'CA'
-				GROUP BY c
-				ORDER BY itemCount
-				""");
-	}
-
-	@Test
-	void theRest12() {
-
-		assertQuery("""
-				SELECT NEW com.acme.example.CustomerDetails(c.id, c.status, o.count)
-				FROM Customer c JOIN c.orders o
-				WHERE o.count > 100
-				""");
-	}
-
-	@Test
-	void theRest13() {
-
-		assertQuery("""
-				SELECT e.address AS addr
-				FROM Employee e
-				""");
-	}
-
-	@Test
-	void theRest14() {
-
-		assertQuery("""
-				SELECT AVG(o.quantity) FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest15() {
-
-		assertQuery("""
-				SELECT SUM(l.price)
-				FROM Order o JOIN o.lineItems l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				""");
-	}
-
-	@Test
-	void theRest16() {
-
-		assertQuery("""
-				SELECT COUNT(o) FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest17() {
-
-		assertQuery("""
-				SELECT COUNT(l.price)
-				FROM Order o JOIN o.lineItems l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				""");
-	}
-
-	@Test
-	void theRest18() {
-
-		assertQuery("""
-				SELECT COUNT(l)
-				FROM Order o JOIN o.lineItems l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John' AND l.price IS NOT NULL
-				""");
-	}
-
-	@Test
-	void theRest19() {
-
-		assertQuery("""
-				SELECT o
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA'
-				ORDER BY o.quantity DESC, o.totalcost
-				""");
-	}
-
-	@Test
-	void theRest20() {
-
-		assertQuery("""
-				SELECT o.quantity, a.zipcode
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA'
-				ORDER BY o.quantity, a.zipcode
-				""");
-	}
-
-	@Test
-	void theRest21() {
-
-		assertQuery("""
-				SELECT o.quantity, o.cost * 1.08 AS taxedCost, a.zipcode
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA' AND a.county = 'Santa Clara'
-				ORDER BY o.quantity, taxedCost, a.zipcode
-				""");
-	}
-
-	@Test
-	void theRest22() {
-
-		assertQuery("""
-				SELECT AVG(o.quantity) as q, a.zipcode
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA'
-				GROUP BY a.zipcode
-				ORDER BY q DESC
-				""");
-	}
-
-	@Test
-	void theRest23() {
-
-		assertQuery("""
-				SELECT p.product_name
-				FROM Order o JOIN o.lineItems l JOIN l.product p JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				ORDER BY p.price
-				""");
-	}
-
-	/**
-	 * This query is specifically dubbed illegal in the spec. It may actually be failing for a different reason.
-	 */
-	@Test
-	void theRest24() {
-
-		assertThatExceptionOfType(BadJpqlGrammarException.class).isThrownBy(() -> {
-			assertQuery("""
-					SELECT p.product_name
-					FROM Order o, IN(o.lineItems) l JOIN o.customer c
-					WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-					ORDER BY o.quantity
-					""");
-		});
-	}
-
-	@Test
-	void theRest25() {
-
-		assertQuery("""
-				DELETE
-				FROM Customer c
-				WHERE c.status = 'inactive'
-				""");
-	}
-
-	@Test
-	void theRest26() {
-
-		assertQuery("""
-				DELETE
-				FROM Customer c
-				WHERE c.status = 'inactive'
-				AND c.orders IS EMPTY
-				""");
-	}
-
-	@Test
-	void theRest27() {
-
-		assertQuery("""
-				UPDATE Customer c
-				SET c.status = 'outstanding'
-				WHERE c.balance < 10000
-				""");
-	}
-
-	@Test
-	void theRest28() {
-
-		assertQuery("""
-				UPDATE Employee e
-				SET e.address.building = 22
-				WHERE e.address.building = 14
-				AND e.address.city = 'Santa Clara'
-				AND e.project = 'Jakarta EE'
-				""");
-	}
-
-	@Test
-	void theRest29() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest30() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.shippingAddress.state = 'CA'
-				""");
-	}
-
-	@Test
-	void theRest31() {
-
-		assertQuery("""
-				SELECT DISTINCT o.shippingAddress.state
-				FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest32() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				""");
-	}
-
-	@Test
-	void theRest33() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS NOT EMPTY
-				""");
-	}
-
-	@Test
-	void theRest34() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS EMPTY
-				""");
-	}
-
-	@Test
-	void theRest35() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				WHERE l.shipped = FALSE
-				""");
-	}
-
-	@Test
-	void theRest36() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE
-				NOT (o.shippingAddress.state = o.billingAddress.state AND
-				o.shippingAddress.city = o.billingAddress.city AND
-				o.shippingAddress.street = o.billingAddress.street)
-				""");
-	}
-
-	@Test
-	void theRest37() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.shippingAddress <> o.billingAddress
-				""");
-	}
-
-	@Test
-	void theRest38() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				WHERE l.product.name = ?1
-				""");
-	}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitorUnitTests.java
new file mode 100644
index 0000000000..5c0eb36bc3
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitorUnitTests.java
@@ -0,0 +1,272 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.query;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+import jakarta.persistence.criteria.CriteriaQuery;
+import jakarta.persistence.criteria.Expression;
+import jakarta.persistence.criteria.Nulls;
+import jakarta.persistence.criteria.Path;
+import jakarta.persistence.criteria.Selection;
+
+import java.util.Locale;
+
+import org.hibernate.query.sqm.tree.SqmRenderContext;
+import org.hibernate.query.sqm.tree.select.SqmSelectStatement;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.data.jpa.domain.JpaSort;
+import org.springframework.data.jpa.domain.sample.User;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Verify that {@link JpaSort#unsafe(String...)} works properly with Hibernate via {@link HqlOrderExpressionVisitor}.
+ *
+ * @author Greg Turnquist
+ * @author Mark Paluch
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:application-context.xml")
+@Transactional
+class HqlOrderExpressionVisitorUnitTests {
+
+	@PersistenceContext EntityManager em;
+
+	@Test
+	void genericFunctions() {
+
+		assertThat(renderOrderBy(JpaSort.unsafe("LENGTH(firstname)"), "u"))
+				.startsWithIgnoringCase("order by character_length(u.firstname) asc");
+		assertThat(renderOrderBy(JpaSort.unsafe("char_length(firstname)"), "u"))
+				.startsWithIgnoringCase("order by char_length(u.firstname) asc");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("nlssort(firstname, 'NLS_SORT = XGERMAN_DIN_AI')"), "u"))
+				.startsWithIgnoringCase("order by nlssort(u.firstname, 'NLS_SORT = XGERMAN_DIN_AI')");
+	}
+
+	@Test // GH-3172
+	void cast() {
+
+		assertThatExceptionOfType(UnsupportedOperationException.class)
+				.isThrownBy(() -> renderOrderBy(JpaSort.unsafe("cast(emailAddress as date)"), "u"));
+	}
+
+	@Test // GH-3172
+	void extract() {
+
+		assertThat(renderOrderBy(JpaSort.unsafe("EXTRACT(DAY FROM createdAt)"), "u"))
+				.startsWithIgnoringCase("order by extract(day from u.createdAt)");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("WEEK(createdAt)"), "u"))
+				.startsWithIgnoringCase("order by extract(week from u.createdAt)");
+	}
+
+	@Test // GH-3172
+	void trunc() {
+		assertThat(renderOrderBy(JpaSort.unsafe("TRUNC(age)"), "u")).startsWithIgnoringCase("order by trunc(u.age)");
+	}
+
+	@Test // GH-3172
+	void upperLower() {
+		assertThat(renderOrderBy(JpaSort.unsafe("upper(firstname)"), "u"))
+				.startsWithIgnoringCase("order by upper(u.firstname)");
+		assertThat(renderOrderBy(JpaSort.unsafe("lower(firstname)"), "u"))
+				.startsWithIgnoringCase("order by lower(u.firstname)");
+	}
+
+	@Test // GH-3172
+	void substring() {
+		assertThat(renderOrderBy(JpaSort.unsafe("substring(emailAddress, 0, 3)"), "u"))
+				.startsWithIgnoringCase("order by substring(u.emailAddress, 0, 3) asc");
+		assertThat(renderOrderBy(JpaSort.unsafe("substring(emailAddress, 0)"), "u"))
+				.startsWithIgnoringCase("order by substring(u.emailAddress, 0) asc");
+	}
+
+	@Test // GH-3172
+	void repeat() {
+		assertThat(renderOrderBy(JpaSort.unsafe("repeat('a', 5)"), "u"))
+				.startsWithIgnoringCase("order by repeat('a', 5) asc");
+	}
+
+	@Test // GH-3172
+	void literals() {
+
+		assertThat(renderOrderBy(JpaSort.unsafe("age + 1"), "u")).startsWithIgnoringCase("order by u.age + 1");
+		assertThat(renderOrderBy(JpaSort.unsafe("age + 1l"), "u")).startsWithIgnoringCase("order by u.age + 1");
+		assertThat(renderOrderBy(JpaSort.unsafe("age + 1L"), "u")).startsWithIgnoringCase("order by u.age + 1");
+		assertThat(renderOrderBy(JpaSort.unsafe("age + 1.1"), "u")).startsWithIgnoringCase("order by u.age + 1.1");
+		assertThat(renderOrderBy(JpaSort.unsafe("age + 1.1f"), "u")).startsWithIgnoringCase("order by u.age + 1.1");
+		assertThat(renderOrderBy(JpaSort.unsafe("age + 1.1bi"), "u")).startsWithIgnoringCase("order by u.age + 1.1");
+		assertThat(renderOrderBy(JpaSort.unsafe("age + 1.1bd"), "u")).startsWithIgnoringCase("order by u.age + 1.1");
+		assertThat(renderOrderBy(JpaSort.unsafe("age + 0x12"), "u")).startsWithIgnoringCase("order by u.age + 18");
+	}
+
+	@Test // GH-3172
+	void temporalLiterals() {
+
+		// JDBC
+		assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {ts '2024-01-01 12:34:56'}"), "u"))
+				.startsWithIgnoringCase("order by u.createdAt + '2024-01-01T12:34:56'");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {ts '2012-01-03 09:00:00.000000001'}"), "u"))
+				.startsWithIgnoringCase("order by u.createdAt + '2012-01-03T09:00:00.000000001'");
+
+		// Hibernate NPE
+		assertThatIllegalArgumentException().isThrownBy(() -> renderOrderBy(JpaSort.unsafe("createdAt + {t '12:34:56'}"), "u"));
+
+		assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {d '2024-01-01'}"), "u"))
+				.startsWithIgnoringCase("order by u.createdAt + '2024-01-01'");
+
+		// JPQL
+		assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {ts 2024-01-01 12:34:56}"), "u"))
+				.startsWithIgnoringCase("order by u.createdAt + '2024-01-01T12:34:56'");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {t 12:34:56}"), "u"))
+				.startsWithIgnoringCase("order by u.createdAt + '12:34:56'");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {d 2024-01-01}"), "u"))
+				.startsWithIgnoringCase("order by u.createdAt + '2024-01-01'");
+	}
+
+	@Test // GH-3172
+	void arithmetic() {
+
+		// Hibernate representation bugs, should be sum(u.age)
+		assertThat(renderOrderBy(JpaSort.unsafe("sum(age)"), "u")).startsWithIgnoringCase("order by sum()");
+		assertThat(renderOrderBy(JpaSort.unsafe("min(age)"), "u")).startsWithIgnoringCase("order by min()");
+		assertThat(renderOrderBy(JpaSort.unsafe("max(age)"), "u")).startsWithIgnoringCase("order by max()");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("age"), "u")).startsWithIgnoringCase("order by u.age");
+		assertThat(renderOrderBy(JpaSort.unsafe("age + 1"), "u")).startsWithIgnoringCase("order by u.age + 1");
+		assertThat(renderOrderBy(JpaSort.unsafe("ABS(age) + 1"), "u")).startsWithIgnoringCase("order by abs(u.age) + 1");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("neg(active)"), "u")).startsWithIgnoringCase("order by neg(u.active)");
+		assertThat(renderOrderBy(JpaSort.unsafe("abs(age)"), "u")).startsWithIgnoringCase("order by abs(u.age)");
+		assertThat(renderOrderBy(JpaSort.unsafe("ceiling(age)"), "u")).startsWithIgnoringCase("order by ceiling(u.age)");
+		assertThat(renderOrderBy(JpaSort.unsafe("floor(age)"), "u")).startsWithIgnoringCase("order by floor(u.age)");
+		assertThat(renderOrderBy(JpaSort.unsafe("round(age)"), "u")).startsWithIgnoringCase("order by round(u.age)");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("prod(age, 1)"), "u")).startsWithIgnoringCase("order by prod(u.age, 1)");
+		assertThat(renderOrderBy(JpaSort.unsafe("prod(age, age)"), "u"))
+				.startsWithIgnoringCase("order by prod(u.age, u.age)");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("diff(age, 1)"), "u")).startsWithIgnoringCase("order by diff(u.age, 1)");
+		assertThat(renderOrderBy(JpaSort.unsafe("quot(age, 1)"), "u")).startsWithIgnoringCase("order by quot(u.age, 1)");
+		assertThat(renderOrderBy(JpaSort.unsafe("mod(age, 1)"), "u")).startsWithIgnoringCase("order by mod(u.age, 1)");
+		assertThat(renderOrderBy(JpaSort.unsafe("sqrt(age)"), "u")).startsWithIgnoringCase("order by sqrt(u.age)");
+		assertThat(renderOrderBy(JpaSort.unsafe("exp(age)"), "u")).startsWithIgnoringCase("order by exp(u.age)");
+		assertThat(renderOrderBy(JpaSort.unsafe("ln(age)"), "u")).startsWithIgnoringCase("order by ln(u.age)");
+	}
+
+	@Test // GH-3172
+	@Disabled("HHH-19075")
+	void trim() {
+		assertThat(renderOrderBy(JpaSort.unsafe("trim(leading '.' from lastname)"), "u"))
+				.startsWithIgnoringCase("order by repeat('a', 5) asc");
+	}
+
+	@Test // GH-3172
+	void groupedExpression() {
+		assertThat(renderOrderBy(JpaSort.unsafe("(lastname)"), "u")).startsWithIgnoringCase("order by u.lastname");
+	}
+
+	@Test // GH-3172
+	void tupleExpression() {
+		assertThat(renderOrderBy(JpaSort.unsafe("(firstname, lastname)"), "u"))
+				.startsWithIgnoringCase("order by u.firstname, u.lastname");
+	}
+
+	@Test // GH-3172
+	void concat() {
+		assertThat(renderOrderBy(JpaSort.unsafe("firstname || lastname"), "u"))
+				.startsWithIgnoringCase("order by concat(u.firstname, u.lastname)");
+	}
+
+	@Test // GH-3172
+	void pathBased() {
+
+		String query = renderQuery(JpaSort.unsafe("manager.firstname"), "u");
+
+		assertThat(query).contains("from org.springframework.data.jpa.domain.sample.User u left join u.manager");
+		assertThat(query).contains(".firstname asc nulls last");
+	}
+
+	@Test // GH-3172
+	void caseSwitch() {
+
+		assertThat(renderOrderBy(JpaSort.unsafe("case firstname when 'Oliver' then 'A' else firstname end"), "u"))
+				.startsWithIgnoringCase("order by case u.firstname when 'Oliver' then 'A' else u.firstname end");
+
+		assertThat(renderOrderBy(
+				JpaSort.unsafe("case firstname when 'Oliver' then 'A' when 'Joachim' then 'z' else firstname end"), "u"))
+				.startsWithIgnoringCase(
+						"order by case u.firstname when 'Oliver' then 'A' when 'Joachim' then 'z' else u.firstname end");
+
+		assertThat(renderOrderBy(JpaSort.unsafe("case when age < 31 then 'A' else firstname end"), "u"))
+				.startsWithIgnoringCase("order by case when u.age < 31 then 'A' else u.firstname end");
+
+		assertThat(
+				renderOrderBy(JpaSort.unsafe("case when firstname not in ('Oliver', 'Dave') then 'A' else firstname end"), "u"))
+				.startsWithIgnoringCase(
+						"order by case when u.firstname not in ('Oliver', 'Dave') then 'A' else u.firstname end");
+	}
+
+	private String renderOrderBy(JpaSort sort, String alias) {
+
+		String query = renderQuery(sort, alias);
+
+		String lowerCase = query.toLowerCase(Locale.ROOT);
+		int index = lowerCase.indexOf("order by");
+
+		if (index != -1) {
+			return query.substring(index);
+		}
+
+		return "";
+	}
+
+	CriteriaQuery<User> createQuery(JpaSort sort, String alias) {
+
+		CriteriaQuery<User> query = em.getCriteriaBuilder().createQuery(User.class);
+		Selection<User> from = query.from(User.class).alias(alias);
+		HqlOrderExpressionVisitor extractor = new HqlOrderExpressionVisitor(em.getCriteriaBuilder(), (Path<?>) from,
+				QueryUtils::toExpressionRecursively);
+
+		Expression<?> expression = extractor.createCriteriaExpression(sort.stream().findFirst().get());
+		return query.select(from).orderBy(em.getCriteriaBuilder().asc(expression, Nulls.NONE));
+	}
+
+	@SuppressWarnings("rawtypes")
+	String renderQuery(JpaSort sort, String alias) {
+
+		CriteriaQuery<User> q = createQuery(sort, alias);
+		SqmSelectStatement s = (SqmSelectStatement) q;
+
+		StringBuilder builder = new StringBuilder();
+		s.appendHqlString(builder, SqmRenderContext.simpleContext());
+
+		return builder.toString();
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlParserQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlParserQueryEnhancerUnitTests.java
index ef7b269115..f25e9fc2ee 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlParserQueryEnhancerUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlParserQueryEnhancerUnitTests.java
@@ -30,9 +30,9 @@ public class HqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests {
 	@Override
 	QueryEnhancer createQueryEnhancer(DeclaredQuery query) {
 
-		assumeThat(query.isNativeQuery()).isFalse();
+		assumeThat(query.isNative()).isFalse();
 
-		return JpaQueryEnhancer.forHql(query);
+		return JpaQueryEnhancer.forHql(query.getQueryString());
 	}
 
 	@Override
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlQueryRendererTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlQueryRendererTests.java
index cdb81af4d4..bcbdf8c178 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlQueryRendererTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlQueryRendererTests.java
@@ -19,6 +19,8 @@
 
 import java.util.stream.Stream;
 
+import org.antlr.v4.runtime.CharStreams;
+import org.antlr.v4.runtime.CommonTokenStream;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
@@ -41,12 +43,10 @@
  */
 class HqlQueryRendererTests {
 
-	private static final String SPEC_FAULT = "Disabled due to spec fault> ";
-
 	/**
 	 * Parse the query using {@link HqlParser} then run it through the query-preserving {@link HqlQueryRenderer}.
 	 */
-	private static String parseWithoutChanges(String query) {
+	static String parseWithoutChanges(String query) {
 
 		JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser.parseQuery(query);
 
@@ -71,33 +71,6 @@ private String reduceWhitespace(String original) {
 				.trim();
 	}
 
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
-	 */
-	@Test
-	void joinExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order AS o JOIN o.lineItems AS l
-				WHERE l.shipped = FALSE
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#identification-variables
-	 */
-	@Test
-	void joinExample2() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l JOIN l.product p
-				WHERE p.productType = 'office_supplies'
-				""");
-	}
-
 	/**
 	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#range-variable-declarations
 	 */
@@ -169,11 +142,11 @@ void pathExpressionSyntaxExample1() {
 
 		assertQuery("""
 				SELECT DISTINCT l.product
-				FROM Order AS o JOIN o.lineItems l
+				FROM Order AS o JOIN o.lineItems l LEFT JOIN l.product p
 				""");
 	}
 
-	@Test // GH-3711
+	@Test // GH-3711, GH-2970
 	void entityTypeReference() {
 
 		assertQuery("""
@@ -185,6 +158,42 @@ SELECT TYPE(e)
 				SELECT TYPE(?0)
 				FROM Employee e
 				""");
+
+		assertQuery("""
+				SELECT e
+				FROM Employee e
+				WHERE TYPE(e) IN (Exempt, Contractor)
+				""");
+
+		assertQuery("""
+				SELECT e
+				FROM Employee e
+				WHERE TYPE(e) IN (:empType1, :empType2)
+				""");
+
+		assertQuery("""
+				SELECT e
+				FROM Employee e
+				WHERE TYPE(e) IN :empTypes
+				""");
+
+		assertQuery("""
+				SELECT TYPE(e)
+				FROM Employee e
+				WHERE TYPE(e) <> Exempt
+				""");
+
+		assertQuery("""
+				SELECT TYPE(e)
+				FROM Employee e
+				WHERE TYPE(e) != Exempt
+				""");
+
+		assertQuery("""
+				SELECT TYPE(e)
+				FROM Employee e
+				WHERE TYPE(e) ^= Exempt
+				""");
 	}
 
 	@Test // GH-3711
@@ -347,6 +356,366 @@ SELECT some_function().foo
 				""");
 	}
 
+	@ParameterizedTest // GH-3689
+	@ValueSource(strings = { "RESPECT NULLS", "IGNORE NULLS" })
+	void generic(String nullHandling) {
+
+		// not in the official documentation but supported in the grammar.
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE FOO(x).bar %s
+				""".formatted(nullHandling));
+	}
+
+	@Test // GH-3689
+	void size() {
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE SIZE(x) > 1
+				""");
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE SIZE(e.skills) > 1
+				""");
+	}
+
+	@Test // GH-3689
+	void collectionAggregate() {
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE MAXELEMENT(foo) > MINELEMENT(bar)
+				""");
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE MININDEX(foo) > MAXINDEX(bar)
+				""");
+	}
+
+	@Test // GH-3689
+	void trunc() {
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE TRUNC(x) = TRUNCATE(y)
+				""");
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE TRUNC(e, 'foo') = TRUNCATE(e, 'bar')
+				""");
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE TRUNC(e, 'YEAR') = TRUNCATE(LOCAL DATETIME, 'YEAR')
+				""");
+	}
+
+	@ParameterizedTest // GH-3689
+	@ValueSource(strings = { "YEAR", "MONTH", "DAY", "WEEK", "QUARTER", "HOUR", "MINUTE", "SECOND", "NANOSECOND",
+			"NANOSECOND", "EPOCH" })
+	void trunc(String truncation) {
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE TRUNC(e, %1$s) = TRUNCATE(e, %1$s)
+				""".formatted(truncation));
+	}
+
+	@Test // GH-3689
+	void format() {
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE FORMAT(x AS 'yyyy') = FORMAT(e.hiringDate AS 'yyyy')
+				""");
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE e.hiringDate = format(LOCAL DATETIME as 'yyyy-MM-dd')
+				""");
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE e.hiringDate = format(LOCAL_DATE() as 'yyyy-MM-dd')
+				""");
+	}
+
+	@Test // GH-3689
+	void collate() {
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				WHERE COLLATE(x AS ucs_basic) = COLLATE(e.name AS ucs_basic)
+				""");
+	}
+
+	@Test // GH-3689
+	void substring() {
+
+		assertQuery("select substring(c.number, 1, 2) " + //
+				"from Call c");
+
+		assertQuery("select substring(c.number, 1) " + //
+				"from Call c");
+
+		assertQuery("select substring(c.number, 1, position('/0' in c.number)) " + //
+				"from Call c");
+
+		assertQuery("select substring(c.number FROM 1 FOR 2) " + //
+				"from Call c");
+
+		assertQuery("select substring(c.number FROM 1) " + //
+				"from Call c");
+
+		assertQuery("select substring(c.number FROM 1 FOR position('/0' in c.number)) " + //
+				"from Call c");
+
+		assertQuery("select substring(c.number FROM 1) AS shortNumber " + //
+				"from Call c");
+	}
+
+	@Test // GH-3689
+	void overlay() {
+
+		assertQuery("select OVERLAY(c.number PLACING 1 FROM 2) " + //
+				"from Call c ");
+
+		assertQuery("select OVERLAY(p.number PLACING 1 FROM 2 FOR 3) " + //
+				"from Call c ");
+	}
+
+	@Test // GH-3689
+	void pad() {
+
+		assertQuery("select PAD(c.number WITH 1 LEADING) " + //
+				"from Call c ");
+
+		assertQuery("select PAD(c.number WITH 1 TRAILING) " + //
+				"from Call c ");
+
+		assertQuery("select PAD(c.number WITH 1 LEADING '0') " + //
+				"from Call c ");
+
+		assertQuery("select PAD(c.number WITH 1 TRAILING '0') " + //
+				"from Call c ");
+	}
+
+	@Test // GH-3689
+	void position() {
+
+		assertQuery("select POSITION(c.number IN 'foo') " + //
+				"from Call c ");
+
+		assertQuery("select POSITION(c.number IN 'foo') + 1 AS pos " + //
+				"from Call c ");
+	}
+
+	@Test // GH-3689
+	void currentDateFunctions() {
+
+		assertQuery("select CURRENT DATE, CURRENT_DATE() " + //
+				"from Call c ");
+
+		assertQuery("select CURRENT TIME, CURRENT_TIME() " + //
+				"from Call c ");
+
+		assertQuery("select CURRENT TIMESTAMP, CURRENT_TIMESTAMP() " + //
+				"from Call c ");
+
+		assertQuery("select INSTANT, CURRENT_INSTANT() " + //
+				"from Call c ");
+
+		assertQuery("select LOCAL DATE, LOCAL_DATE() " + //
+				"from Call c ");
+
+		assertQuery("select LOCAL TIME, LOCAL_TIME() " + //
+				"from Call c ");
+
+		assertQuery("select LOCAL DATETIME, LOCAL_DATETIME() " + //
+				"from Call c ");
+
+		assertQuery("select OFFSET DATETIME, OFFSET_DATETIME() " + //
+				"from Call c ");
+
+		assertQuery("select OFFSET DATETIME AS offsetDatetime, OFFSET_DATETIME() AS offset_datetime " + //
+				"from Call c ");
+	}
+
+	@Test // GH-3689
+	void cube() {
+
+		assertQuery("select CUBE(foo), CUBE(foo, bar) " + //
+				"from Call c ");
+
+		assertQuery("select c.callerId from Call c GROUP BY CUBE(state, province)");
+	}
+
+	@Test // GH-3689
+	void rollup() {
+
+		assertQuery("select ROLLUP(foo), ROLLUP(foo, bar) " + //
+				"from Call c ");
+
+		assertQuery("select c.callerId from Call c GROUP BY ROLLUP(state, province)");
+	}
+
+	@Test
+	void pathExpressionsNamedParametersExample() {
+
+		assertQuery("""
+				SELECT c
+				FROM Customer c
+				WHERE c.status = :stat
+				""");
+	}
+
+	@Test
+	void betweenExpressionsExample() {
+
+		assertQuery("""
+				SELECT t
+				FROM CreditCard c JOIN c.transactionHistory t
+				WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9
+				""");
+	}
+
+	@Test
+	void isEmptyExample() {
+
+		assertQuery("""
+				SELECT o
+				FROM Order o
+				WHERE o.lineItems IS EMPTY
+				""");
+	}
+
+	@Test
+	void memberOfExample() {
+
+		assertQuery("""
+				SELECT p
+				FROM Person p
+				WHERE 'Joe' MEMBER OF p.nicknames
+				""");
+	}
+
+	@Test
+	void existsSubSelectExample1() {
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE EXISTS (SELECT spouseEmp
+				    FROM Employee spouseEmp
+				        WHERE spouseEmp = emp.spouse)
+				""");
+	}
+
+	@Test // GH-3689
+	void everyAll() {
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE EVERY (SELECT spouseEmp
+				    FROM Employee spouseEmp) > 1
+				""");
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE ALL (SELECT spouseEmp
+				    FROM Employee spouseEmp) > 1
+				""");
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE ALL (foo > 1) OVER (PARTITION BY bar)
+				""");
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE ALL VALUES(foo) > 1
+				""");
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE ALL ELEMENTS(foo) > 1
+				""");
+	}
+
+	@Test // GH-3689
+	void anySome() {
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE ANY (SELECT spouseEmp
+				    FROM Employee spouseEmp) > 1
+				""");
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE SOME (SELECT spouseEmp
+				    FROM Employee spouseEmp) > 1
+				""");
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE ANY (foo > 1) OVER (PARTITION BY bar)
+				""");
+
+		assertQuery("""
+				SELECT DISTINCT emp
+				FROM Employee emp
+				WHERE ANY VALUES(foo) > 1
+				""");
+	}
+
+	@Test // GH-3689
+	void listAgg() {
+
+		assertQuery("select listagg(p.number, ', ') within group (order by p.type, p.number) " + //
+				"from Phone p " + //
+				"group by p.person");
+	}
+
+	/**
+	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
+	 */
+	@Test
+	void joinExample1() {
+
+		assertQuery("""
+				SELECT DISTINCT o
+				FROM Order AS o JOIN o.lineItems AS l
+				WHERE l.shipped = FALSE
+				""");
+	}
+
+	/**
+	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
+	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#identification-variables
+	 */
+	@Test
+	void joinExample2() {
+
+		assertQuery("""
+				SELECT DISTINCT o
+				FROM Order o JOIN o.lineItems l JOIN l.product p
+				WHERE p.productType = 'office_supplies'
+				""");
+	}
+
 	@Test
 	void joinsExample1() {
 
@@ -371,7 +740,6 @@ void joinsInnerExample() {
 				""");
 	}
 
-	@Disabled("Deprecated syntax dating back to EJB-QL prior to EJB 3, required by JPA, never documented in Hibernate")
 	@Test
 	void joinsInExample() {
 
@@ -447,7 +815,7 @@ void collectionMemberInExample() {
 
 		assertQuery("""
 				SELECT DISTINCT o
-				FROM Order o , IN(o.lineItems) l
+				FROM Order o, IN(o.lineItems) l
 				WHERE l.product.productType = 'office_supplies'
 				""");
 	}
@@ -483,8 +851,7 @@ SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp
 	 * @see #fromClauseDowncastingExample3fixed()
 	 */
 	@Test
-	@Disabled(SPEC_FAULT + "Use double-quotes when it should be using single-quotes for a string literal")
-	void fromClauseDowncastingExample3_SPEC_BUG() {
+	void fromClauseDowncastingExample3() {
 
 		assertQuery("""
 				SELECT e FROM Employee e JOIN e.projects p
@@ -492,10 +859,6 @@ WHERE TREAT(p AS LargeProject).budget > 1000
 				    OR TREAT(p AS SmallProject).name LIKE 'Persist%'
 				    OR p.description LIKE "cost overrun"
 				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample3fixed() {
 
 		assertQuery("""
 				SELECT e FROM Employee e JOIN e.projects p
@@ -515,58 +878,6 @@ OR TREAT(e AS Contractor).hours > 100
 				""");
 	}
 
-	@Test
-	void pathExpressionsNamedParametersExample() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.status = :stat
-				""");
-	}
-
-	@Test
-	void betweenExpressionsExample() {
-
-		assertQuery("""
-				SELECT t
-				FROM CreditCard c JOIN c.transactionHistory t
-				WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9
-				""");
-	}
-
-	@Test
-	void isEmptyExample() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS EMPTY
-				""");
-	}
-
-	@Test
-	void memberOfExample() {
-
-		assertQuery("""
-				SELECT p
-				FROM Person p
-				WHERE 'Joe' MEMBER OF p.nicknames
-				""");
-	}
-
-	@Test
-	void existsSubSelectExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE EXISTS (SELECT spouseEmp
-				    FROM Employee spouseEmp
-				        WHERE spouseEmp = emp.spouse)
-				""");
-	}
-
 	@Test
 	void allExample() {
 
@@ -626,8 +937,7 @@ AND INDEX(w) = 0
 	 * @see #functionInvocationExampleWithCorrection()
 	 */
 	@Test
-	@Disabled(SPEC_FAULT + "FUNCTION calls needs a comparator")
-	void functionInvocationExample_SPEC_BUG() {
+	void functionInvocationExample() {
 
 		assertQuery("""
 				SELECT c
@@ -646,6 +956,15 @@ WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE
 				""");
 	}
 
+	@ParameterizedTest // GH-3628
+	@ValueSource(strings = { "is true", "is not true", "is false", "is not false" })
+	void functionInvocationWithIsBoolean(String booleanComparison) {
+
+		assertQuery("""
+				from RoleTmpl where find_in_set(:appId, appIds) %s
+				""".formatted(booleanComparison));
+	}
+
 	@Test
 	void updateCaseExample1() {
 
@@ -703,61 +1022,95 @@ void selectCaseExample2() {
 	}
 
 	@Test
-	void theRest() {
+	void collectionIsEmpty() {
 
 		assertQuery("""
-				SELECT e
-				FROM Employee e
-				WHERE TYPE(e) IN (Exempt, Contractor)
+				DELETE
+				FROM Customer c
+				WHERE c.status = 'inactive'
+				AND c.orders IS EMPTY
 				""");
-	}
-
-	@Test
-	void theRest2() {
 
 		assertQuery("""
-				SELECT e
-				FROM Employee e
-				WHERE TYPE(e) IN (:empType1, :empType2)
+				DELETE
+				FROM Customer c
+				WHERE c.status = 'inactive'
+				AND c.orders IS NOT EMPTY
 				""");
 	}
 
-	@Test
-	void theRest3() {
+	@Test // GH-3628
+	void booleanPredicate() {
 
 		assertQuery("""
-				SELECT e
-				FROM Employee e
-				WHERE TYPE(e) IN :empTypes
+				SELECT c
+				FROM Customer c
+				WHERE c.orders IS TRUE
 				""");
-	}
 
-	@Test
-	void theRest4() {
+		assertQuery("""
+				SELECT c
+				FROM Customer c
+				WHERE c.orders IS NOT TRUE
+				""");
 
 		assertQuery("""
-				SELECT TYPE(e)
-				FROM Employee e
-				WHERE TYPE(e) <> Exempt
+				SELECT c
+				FROM Customer c
+				WHERE c.orders IS FALSE
 				""");
-	}
 
-	@Test // GH-2970
-	void alternateNotEqualsShouldAlsoWork() {
+		assertQuery("""
+				SELECT c
+				FROM Customer c
+				WHERE c.orders IS NOT FALSE
+				""");
 
 		assertQuery("""
-				SELECT TYPE(e)
-				FROM Employee e
-				WHERE TYPE(e) != Exempt
+				SELECT c
+				FROM Customer c
+				WHERE c.orders IS NULL
 				""");
 
 		assertQuery("""
-				SELECT TYPE(e)
-				FROM Employee e
-				WHERE TYPE(e) ^= Exempt
+				SELECT c
+				FROM Customer c
+				WHERE c.orders IS NOT NULL
 				""");
 	}
 
+	@ParameterizedTest // GH-3628
+	@ValueSource(strings = { "IS DISTINCT FROM", "IS NOT DISTINCT FROM" })
+	void distinctFromPredicate(String distinctFrom) {
+
+		assertQuery("""
+				SELECT c
+				FROM Customer c
+				WHERE c.orders %s c.payments
+				""".formatted(distinctFrom));
+
+		assertQuery("""
+				SELECT c
+				FROM Customer c
+				WHERE c.orders %s c.payments
+				""".formatted(distinctFrom));
+
+		assertQuery("""
+				SELECT c
+				FROM Customer c
+				GROUP BY c.lastname
+				HAVING c.orders %s c.payments
+				""".formatted(distinctFrom));
+
+		assertQuery("""
+				SELECT c
+				FROM Customer c
+				WHERE EXISTS (SELECT c2
+				    FROM Customer c2
+				        WHERE c2.orders %s c.orders)
+				""".formatted(distinctFrom));
+	}
+
 	@Test
 	void theRest5() {
 
@@ -791,6 +1144,18 @@ HAVING COUNT(o) >= 5
 				""");
 	}
 
+	@Test
+	void shouldRenderHavingWithFunction() {
+
+		assertQuery("""
+				SELECT COUNT(f)
+				FROM FooEntity f
+				WHERE f.name IN ('Y', 'Basic', 'Remit')
+							AND f.size = 10
+				HAVING COUNT(f) > 0
+				""");
+	}
+
 	@Test
 	void theRest8() {
 
@@ -960,7 +1325,7 @@ void theRest24() {
 
 		assertQuery("""
 				SELECT p.product_name
-				FROM Order o , IN(o.lineItems) l JOIN o.customer c
+				FROM Order o, IN(o.lineItems) l JOIN o.customer c
 				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
 				ORDER BY o.quantity
 				""");
@@ -1109,85 +1474,108 @@ void theRest38() {
 				""");
 	}
 
+	@Test // GH-3689
+	void insertQueries() {
+
+		assertQuery("insert Person (id, name) values (100L, 'Jane Doe')");
+
+		assertQuery("insert Person (id, name) values " + //
+				"(101L, 'J A Doe III'), " + //
+				"(102L, 'J X Doe'), " + //
+				"(103L, 'John Doe, Jr')");
+
+		assertQuery("insert into Partner (id, name) " + //
+				"select p.id, p.name from Person p ");
+
+		assertQuery("INSERT INTO AggregationPrice (range, price, type) " + "VALUES (:range, :price, :priceType) "
+				+ "ON CONFLICT (range) DO UPDATE  SET price = :price, type = :priceType");
+
+		assertQuery("INSERT INTO AggregationPrice (range, price, type) " + "VALUES (:range, :price, :priceType) "
+				+ "ON CONFLICT ON CONSTRAINT foo DO UPDATE  SET price = :price, type = :priceType");
+
+		assertQuery("INSERT INTO AggregationPrice (range, price, type) " + "VALUES (:range, :price, :priceType) "
+				+ "ON CONFLICT ON CONSTRAINT foo DO NOTHING");
+	}
+
 	@Test
 	void hqlQueries() {
 
-		parseWithoutChanges("from Person");
-		parseWithoutChanges("select local datetime");
-		parseWithoutChanges("from Person p select p.name");
-		parseWithoutChanges("update Person set nickName = 'Nacho' " + //
+		assertQuery("from Person");
+		assertQuery("select local datetime");
+		assertQuery("from Person p select p.name");
+		assertQuery("update Person set nickName = 'Nacho' " + //
 				"where name = 'Ignacio'");
-		parseWithoutChanges("update Person p " + //
+		assertQuery("update Person p " + //
 				"set p.name = :newName " + //
 				"where p.name = :oldName");
-		parseWithoutChanges("update Person " + //
+		assertQuery("update Person " + //
 				"set name = :newName " + //
 				"where name = :oldName");
-		parseWithoutChanges("update versioned Person " + //
+		assertQuery("update versioned Person " + //
 				"set name = :newName " + //
 				"where name = :oldName");
-		parseWithoutChanges("insert Person (id, name) " + //
+		assertQuery("insert Person (id, name) " + //
 				"values (100L, 'Jane Doe')");
-		parseWithoutChanges("insert Person (id, name) " + //
+		assertQuery("insert Person (id, name) " + //
 				"values (101L, 'J A Doe III'), " + //
 				"(102L, 'J X Doe'), " + //
 				"(103L, 'John Doe, Jr')");
-		parseWithoutChanges("insert into Partner (id, name) " + //
+		assertQuery("insert into Partner (id, name) " + //
 				"select p.id, p.name " + //
 				"from Person p ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.name like 'Joe'");
 
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.name ilike 'Joe'");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.name like 'Joe''s'");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.id = 1");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.id = 1L");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"where c.duration > 100.5");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"where c.duration > 100.5F");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"where c.duration > 1e+2");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"where c.duration > 1e+2F");
-		parseWithoutChanges("from Phone ph " + //
+		assertQuery("from Phone ph " + //
 				"where ph.type = LAND_LINE");
-		parseWithoutChanges("select java.lang.Math.PI");
-		parseWithoutChanges("select 'Customer ' || p.name " + //
+		assertQuery("select java.lang.Math.PI");
+		assertQuery("select 'Customer ' || p.name " + //
 				"from Person p " + //
 				"where p.id = 1");
-		parseWithoutChanges("select sum(ch.duration) * :multiplier " + //
+		assertQuery("select sum(ch.duration) * :multiplier " + //
 				"from Person pr " + //
 				"join pr.phones ph " + //
 				"join ph.callHistory ch " + //
 				"where ph.id = 1L ");
-		parseWithoutChanges("select year(local date) - year(p.createdOn) " + //
+		assertQuery("select year(local date) - year(p.createdOn) " + //
 				"from Person p " + //
 				"where p.id = 1L");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where year(local date) - year(p.createdOn) > 1");
-		parseWithoutChanges("select " + //
+		assertQuery("select " + //
 				"	case p.nickName " + //
 				"	when 'NA' " + //
 				"	then '<no nick name>' " + //
 				"	else p.nickName " + //
 				"	end " + //
 				"from Person p");
-		parseWithoutChanges("select " + //
+		assertQuery("select " + //
 				"	case " + //
 				"	when p.nickName is null " + //
 				"	then " + //
@@ -1199,336 +1587,336 @@ void hqlQueries() {
 				"	else p.nickName " + //
 				"	end " + //
 				"from Person p");
-		parseWithoutChanges("select " + //
+		assertQuery("select " + //
 				"	case when p.nickName is null " + //
 				"		 then p.id * 1000 " + //
 				"		 else p.id " + //
 				"	end " + //
 				"from Person p " + //
 				"order by p.id");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Payment p " + //
 				"where type(p) = CreditCardPayment");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Payment p " + //
 				"where type(p) = :type");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Payment p " + //
 				"where length(treat(p as CreditCardPayment).cardNumber) between 16 and 20");
-		parseWithoutChanges("select nullif(p.nickName, p.name) " + //
+		assertQuery("select nullif(p.nickName, p.name) " + //
 				"from Person p");
-		parseWithoutChanges("select " + //
+		assertQuery("select " + //
 				"	case" + //
 				"	when p.nickName = p.name" + //
 				"	then null" + //
 				"	else p.nickName" + //
 				"	end " + //
 				"from Person p");
-		parseWithoutChanges("select coalesce(p.nickName, '<no nick name>') " + //
+		assertQuery("select coalesce(p.nickName, '<no nick name>') " + //
 				"from Person p");
-		parseWithoutChanges("select coalesce(p.nickName, p.name, '<no nick name>') " + //
+		assertQuery("select coalesce(p.nickName, p.name, '<no nick name>') " + //
 				"from Person p");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where size(p.phones) >= 2");
-		parseWithoutChanges("select concat(p.number, ' : ' , cast(c.duration as string)) " + //
+		assertQuery("select concat(p.number, ' : ', cast(c.duration as string)) " + //
 				"from Call c " + //
 				"join c.phone p");
-		parseWithoutChanges("select substring(p.number, 1, 2) " + //
+		assertQuery("select substring(p.number, 1, 2) " + //
 				"from Call c " + //
 				"join c.phone p");
-		parseWithoutChanges("select upper(p.name) " + //
+		assertQuery("select upper(p.name) " + //
 				"from Person p ");
-		parseWithoutChanges("select lower(p.name) " + //
+		assertQuery("select lower(p.name) " + //
 				"from Person p ");
-		parseWithoutChanges("select trim(p.name) " + //
+		assertQuery("select trim(p.name) " + //
 				"from Person p ");
-		parseWithoutChanges("select trim(leading ' ' from p.name) " + //
+		assertQuery("select trim(leading ' ' from p.name) " + //
 				"from Person p ");
-		parseWithoutChanges("select length(p.name) " + //
+		assertQuery("select length(p.name) " + //
 				"from Person p ");
-		parseWithoutChanges("select locate('John', p.name) " + //
+		assertQuery("select locate('John', p.name) " + //
 				"from Person p ");
-		parseWithoutChanges("select abs(c.duration) " + //
+		assertQuery("select abs(c.duration) " + //
 				"from Call c ");
-		parseWithoutChanges("select mod(c.duration, 10) " + //
+		assertQuery("select mod(c.duration, 10) " + //
 				"from Call c ");
-		parseWithoutChanges("select sqrt(c.duration) " + //
+		assertQuery("select sqrt(c.duration) " + //
 				"from Call c ");
-		parseWithoutChanges("select cast(c.duration as String) " + //
+		assertQuery("select cast(c.duration as String) " + //
 				"from Call c ");
-		parseWithoutChanges("select str(c.timestamp) " + //
+		assertQuery("select str(c.timestamp) " + //
 				"from Call c ");
-		parseWithoutChanges("select str(cast(duration as float) / 60, 4, 2) " + //
+		assertQuery("select str(cast(duration as float) / 60, 4, 2) " + //
 				"from Call c ");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"where extract(date from c.timestamp) = local date");
-		parseWithoutChanges("select extract(year from c.timestamp) " + //
+		assertQuery("select extract(year from c.timestamp) " + //
 				"from Call c ");
-		parseWithoutChanges("select year(c.timestamp) " + //
+		assertQuery("select year(c.timestamp) " + //
 				"from Call c ");
-		parseWithoutChanges("select var_samp(c.duration) as sampvar, var_pop(c.duration) as popvar " + //
+		assertQuery("select var_samp(c.duration) as sampvar, var_pop(c.duration) as popvar " + //
 				"from Call c ");
-		parseWithoutChanges("select bit_length(c.phone.number) " + //
+		assertQuery("select bit_length(c.phone.number) " + //
 				"from Call c ");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"where c.duration < 30 ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.name like 'John%' ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.createdOn > '1950-01-01' ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Phone p " + //
 				"where p.type = 'MOBILE' ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Payment p " + //
 				"where p.completed = true ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Payment p " + //
 				"where type(p) = WireTransferPayment ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Payment p, Phone ph " + //
 				"where p.person = ph.person ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"join p.phones ph " + //
 				"where p.id = 1L and index(ph) between 0 and 3");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.createdOn between '1999-01-01' and '2001-01-02'");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"where c.duration between 5 and 20");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.name between 'H' and 'M'");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.nickName is not null");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.nickName is null");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.name like 'Jo%'");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.name not like 'Jo%'");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.name like 'Dr|_%' escape '|'");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Payment p " + //
 				"where type(p) in (CreditCardPayment, WireTransferPayment)");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Phone p " + //
 				"where type in ('MOBILE', 'LAND_LINE')");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Phone p " + //
 				"where type in :types");
-		parseWithoutChanges("select distinct p " + //
+		assertQuery("select distinct p " + //
 				"from Phone p " + //
-				"where p.person.id in (" + //
-				"	select py.person.id " + //
+				"where p.person.id in " + //
+				"(select py.person.id " + //
 				"	from Payment py" + //
-				"	where py.completed = true and py.amount > 50 " + //
+				"	where py.completed = true and py.amount > 50" + //
 				")");
-		parseWithoutChanges("select distinct p " + //
+		assertQuery("select distinct p " + //
 				"from Phone p " + //
-				"where p.person in (" + //
-				"	select py.person " + //
+				"where p.person in " + //
+				"(select py.person " + //
 				"	from Payment py" + //
-				"	where py.completed = true and py.amount > 50 " + //
+				"	where py.completed = true and py.amount > 50" + //
 				")");
-		parseWithoutChanges("select distinct p " + //
+		assertQuery("select distinct p " + //
 				"from Payment p " + //
 				"where (p.amount, p.completed) in (" + //
-				"	(50, true)," + //
+				"(50, true)," + //
 				"	(100, true)," + //
 				"	(5, false)" + //
 				")");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where 1 in indices(p.phones)");
-		parseWithoutChanges("select distinct p.person " + //
+		assertQuery("select distinct p.person " + //
 				"from Phone p " + //
 				"join p.calls c " + //
-				"where 50 > all (" + //
-				"	select duration" + //
+				"where 50 > all " + //
+				"(select duration" + //
 				"	from Call" + //
-				"	where phone = p " + //
+				"	where phone = p" + //
 				") ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Phone p " + //
 				"where local date > all elements(p.repairTimestamps)");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where :phone = some elements(p.phones)");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where :phone member of p.phones");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where exists elements(p.phones)");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.phones is empty");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.phones is not empty");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.phones is not empty");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where 'Home address' member of p.addresses");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where 'Home address' not member of p.addresses");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from org.hibernate.userguide.model.Person p");
-		parseWithoutChanges("select distinct pr, ph " + //
+		assertQuery("select distinct pr, ph " + //
 				"from Person pr, Phone ph " + //
 				"where ph.person = pr and ph is not null");
-		parseWithoutChanges("select distinct pr1 " + //
+		assertQuery("select distinct pr1 " + //
 				"from Person pr1, Person pr2 " + //
 				"where pr1.id <> pr2.id " + //
 				"  and pr1.address = pr2.address " + //
 				"  and pr1.createdOn < pr2.createdOn");
-		parseWithoutChanges("select distinct pr, ph " + //
+		assertQuery("select distinct pr, ph " + //
 				"from Person pr cross join Phone ph " + //
 				"where ph.person = pr and ph is not null");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Payment p ");
-		parseWithoutChanges("select d.owner, d.payed " + //
-				"from (" + //
-				"  select p.person as owner, c.payment is not null as payed " + //
+		assertQuery("select d.owner, d.payed " + //
+				"from " + //
+				"(select p.person as owner, c.payment is not null as payed " + //
 				"  from Call c " + //
 				"  join c.phone p " + //
 				"  where p.number = :phoneNumber) d");
-		parseWithoutChanges("select distinct pr " + //
+		assertQuery("select distinct pr " + //
 				"from Person pr " + //
 				"join Phone ph on ph.person = pr " + //
 				"where ph.type = :phoneType");
-		parseWithoutChanges("select distinct pr " + //
+		assertQuery("select distinct pr " + //
 				"from Person pr " + //
 				"join pr.phones ph " + //
 				"where ph.type = :phoneType");
-		parseWithoutChanges("select distinct pr " + //
+		assertQuery("select distinct pr " + //
 				"from Person pr " + //
 				"inner join pr.phones ph " + //
 				"where ph.type = :phoneType");
-		parseWithoutChanges("select distinct pr " + //
+		assertQuery("select distinct pr " + //
 				"from Person pr " + //
 				"left join pr.phones ph " + //
 				"where ph is null " + //
 				"   or ph.type = :phoneType");
-		parseWithoutChanges("select distinct pr " + //
+		assertQuery("select distinct pr " + //
 				"from Person pr " + //
 				"left outer join pr.phones ph " + //
 				"where ph is null " + //
 				"   or ph.type = :phoneType");
-		parseWithoutChanges("select pr.name, ph.number " + //
+		assertQuery("select pr.name, ph.number " + //
 				"from Person pr " + //
 				"left join pr.phones ph with ph.type = :phoneType ");
-		parseWithoutChanges("select pr.name, ph.number " + //
+		assertQuery("select pr.name, ph.number " + //
 				"from Person pr " + //
 				"left join pr.phones ph on ph.type = :phoneType ");
-		parseWithoutChanges("select distinct pr " + //
+		assertQuery("select distinct pr " + //
 				"from Person pr " + //
 				"left join fetch pr.phones ");
-		parseWithoutChanges("select a, ccp " + //
+		assertQuery("select a, ccp " + //
 				"from Account a " + //
 				"join treat(a.payments as CreditCardPayment) ccp " + //
 				"where length(ccp.cardNumber) between 16 and 20");
-		parseWithoutChanges("select c, ccp " + //
+		assertQuery("select c, ccp " + //
 				"from Call c " + //
 				"join treat(c.payment as CreditCardPayment) ccp " + //
 				"where length(ccp.cardNumber) between 16 and 20");
-		parseWithoutChanges("select longest.duration " + //
+		assertQuery("select longest.duration " + //
 				"from Phone p " + //
-				"left join lateral (" + //
-				"  select c.duration as duration " + //
+				"left join lateral " + //
+				"(select c.duration as duration " + //
 				"  from p.calls c" + //
 				"  order by c.duration desc" + //
 				"  limit 1 " + //
 				"  ) longest " + //
 				"where p.number = :phoneNumber");
-		parseWithoutChanges("select ph " + //
+		assertQuery("select ph " + //
 				"from Phone ph " + //
 				"where ph.person.address = :address ");
-		parseWithoutChanges("select ph " + //
+		assertQuery("select ph " + //
 				"from Phone ph " + //
 				"join ph.person pr " + //
 				"where pr.address = :address ");
-		parseWithoutChanges("select ph " + //
+		assertQuery("select ph " + //
 				"from Phone ph " + //
 				"where ph.person.address = :address " + //
 				"  and ph.person.createdOn > :timestamp");
-		parseWithoutChanges("select ph " + //
+		assertQuery("select ph " + //
 				"from Phone ph " + //
 				"inner join ph.person pr " + //
 				"where pr.address = :address " + //
 				"  and pr.createdOn > :timestamp");
-		parseWithoutChanges("select ph " + //
+		assertQuery("select ph " + //
 				"from Person pr " + //
 				"join pr.phones ph " + //
 				"join ph.calls c " + //
 				"where pr.address = :address " + //
 				"  and c.duration > :duration");
-		parseWithoutChanges("select ch " + //
+		assertQuery("select ch " + //
 				"from Phone ph " + //
 				"join ph.callHistory ch " + //
 				"where ph.id = :id ");
-		parseWithoutChanges("select value(ch) " + //
+		assertQuery("select value(ch) " + //
 				"from Phone ph " + //
 				"join ph.callHistory ch " + //
 				"where ph.id = :id ");
-		parseWithoutChanges("select key(ch) " + //
+		assertQuery("select key(ch) " + //
 				"from Phone ph " + //
 				"join ph.callHistory ch " + //
 				"where ph.id = :id ");
-		parseWithoutChanges("select key(ch) " + //
+		assertQuery("select key(ch) " + //
 				"from Phone ph " + //
 				"join ph.callHistory ch " + //
 				"where ph.id = :id ");
-		parseWithoutChanges("select entry(ch) " + //
+		assertQuery("select entry (ch) " + //
 				"from Phone ph " + //
 				"join ph.callHistory ch " + //
 				"where ph.id = :id ");
-		parseWithoutChanges("select sum(ch.duration) " + //
+		assertQuery("select sum(ch.duration) " + //
 				"from Person pr " + //
 				"join pr.phones ph " + //
 				"join ph.callHistory ch " + //
 				"where ph.id = :id " + //
 				"  and index(ph) = :phoneIndex");
-		parseWithoutChanges("select value(ph.callHistory) " + //
+		assertQuery("select value(ph.callHistory) " + //
 				"from Phone ph " + //
 				"where ph.id = :id ");
-		parseWithoutChanges("select key(ph.callHistory) " + //
+		assertQuery("select key(ph.callHistory) " + //
 				"from Phone ph " + //
 				"where ph.id = :id ");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.phones[0].type = LAND_LINE");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where p.addresses['HOME'] = :address");
-		parseWithoutChanges("select pr " + //
+		assertQuery("select pr " + //
 				"from Person pr " + //
 				"where pr.phones[max(indices(pr.phones))].type = 'LAND_LINE'");
-		parseWithoutChanges("select p.name, p.nickName " + //
+		assertQuery("select p.name, p.nickName " + //
 				"from Person p ");
-		parseWithoutChanges("select p.name as name, p.nickName as nickName " + //
+		assertQuery("select p.name as name, p.nickName as nickName " + //
 				"from Person p ");
-		parseWithoutChanges("select new org.hibernate.userguide.hql.CallStatistics(" + //
-				"	count(c), " + //
+		assertQuery("select new org.hibernate.userguide.hql.CallStatistics" + //
+				"(count(c), " + //
 				"	sum(c.duration), " + //
 				"	min(c.duration), " + //
 				"	max(c.duration), " + //
@@ -1536,100 +1924,99 @@ void hqlQueries() {
 				"	1" + //
 				")  " + //
 				"from Call c ");
-		parseWithoutChanges("select new map(" + //
-				"	p.number as phoneNumber , " + //
+		assertQuery("select new map(" + //
+				"p.number as phoneNumber, " + //
 				"	sum(c.duration) as totalDuration, " + //
-				"	avg(c.duration) as averageDuration " + //
+				"	avg(c.duration) as averageDuration" + //
 				")  " + //
 				"from Call c " + //
 				"join c.phone p " + //
 				"group by p.number ");
-		parseWithoutChanges("select new list(" + //
-				"	p.number, " + //
-				"	c.duration " + //
-				")  " + //
+		assertQuery("select new list(" + //
+				"p.number, " + //
+				"	c.duration) " + //
 				"from Call c " + //
 				"join c.phone p ");
-		parseWithoutChanges("select distinct p.lastName " + //
+		assertQuery("select distinct p.lastName " + //
 				"from Person p");
-		parseWithoutChanges("select " + //
+		assertQuery("select " + //
 				"	count(c), " + //
 				"	sum(c.duration), " + //
 				"	min(c.duration), " + //
 				"	max(c.duration), " + //
 				"	avg(c.duration)  " + //
 				"from Call c ");
-		parseWithoutChanges("select count(distinct c.phone) " + //
+		assertQuery("select count(distinct c.phone) " + //
 				"from Call c ");
-		parseWithoutChanges("select p.number, count(c) " + //
+		assertQuery("select p.number, count(c) " + //
 				"from Call c " + //
 				"join c.phone p " + //
 				"group by p.number");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Phone p " + //
 				"where max(elements(p.calls)) = :call");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Phone p " + //
 				"where min(elements(p.calls)) = :call");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"where max(indices(p.phones)) = 0");
-		parseWithoutChanges("select count(c) filter (where c.duration < 30) " + //
+		assertQuery("select count(c) filter (where c.duration < 30) " + //
 				"from Call c ");
-		parseWithoutChanges("select p.number, count(c) filter (where c.duration < 30) " + //
+		assertQuery("select p.number, count(c) filter (where c.duration < 30) " + //
 				"from Call c " + //
 				"join c.phone p " + //
 				"group by p.number");
-		parseWithoutChanges("select listagg(p.number, ', ') within group (order by p.type,p.number) " + //
+		assertQuery("select listagg(p.number, ', ') within group (order by p.type, p.number) " + //
 				"from Phone p " + //
 				"group by p.person");
-		parseWithoutChanges("select sum(c.duration) " + //
+		assertQuery("select sum(c.duration) " + //
 				"from Call c ");
-		parseWithoutChanges("select p.name, sum(c.duration) " + //
+		assertQuery("select p.name, sum(c.duration) " + //
 				"from Call c " + //
 				"join c.phone ph " + //
 				"join ph.person p " + //
 				"group by p.name");
-		parseWithoutChanges("select p, sum(c.duration) " + //
+		assertQuery("select p, sum(c.duration) " + //
 				"from Call c " + //
 				"join c.phone ph " + //
 				"join ph.person p " + //
 				"group by p");
-		parseWithoutChanges("select p.name, sum(c.duration) " + //
+		assertQuery("select p.name, sum(c.duration) " + //
 				"from Call c " + //
 				"join c.phone ph " + //
 				"join ph.person p " + //
 				"group by p.name " + //
 				"having sum(c.duration) > 1000");
-		parseWithoutChanges("select p.name from Person p " + //
+		assertQuery("select p.name from Person p " + //
 				"union " + //
 				"select p.nickName from Person p where p.nickName is not null");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Person p " + //
 				"order by p.name");
-		parseWithoutChanges("select p.name, sum(c.duration) as total " + //
+		assertQuery("select p.name, sum(c.duration) as total " + //
 				"from Call c " + //
 				"join c.phone ph " + //
 				"join ph.person p " + //
 				"group by p.name " + //
 				"order by total");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"join c.phone p " + //
 				"order by p.number " + //
 				"limit 50");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"join c.phone p " + //
 				"order by p.number " + //
 				"fetch first 50 rows only");
-		parseWithoutChanges("select c " + //
+		assertQuery("select c " + //
 				"from Call c " + //
 				"join c.phone p " + //
 				"order by p.number " + //
 				"offset 10 rows " + //
 				"fetch first 50 rows with ties");
-		parseWithoutChanges("select p " + //
+		assertQuery("select p " + //
 				"from Phone p " + //
 				"join fetch p.calls " + //
 				"order by p " + //
@@ -1639,42 +2026,36 @@ void hqlQueries() {
 	@Test // GH-2962
 	void orderByWithNullsFirstOrLastShouldWork() {
 
-		assertThatNoException().isThrownBy(() -> {
-			parseWithoutChanges("""
-					select a,
-						case
-							when a.geaendertAm is null then a.erstelltAm
-							else a.geaendertAm end as mutationAm
-					from Element a
-					where a.erstelltDurch = :variable
-					order by mutationAm desc nulls first
-					""");
-		});
-
-		assertThatNoException().isThrownBy(() -> {
-			parseWithoutChanges("""
-						select a,
-							case
-								when a.geaendertAm is null then a.erstelltAm
-								else a.geaendertAm end as mutationAm
-						from Element a
-						where a.erstelltDurch = :variable
-						order by mutationAm desc nulls last
-					""");
-		});
+		assertQuery("""
+				select a,
+					case
+						when a.geaendertAm is null then a.erstelltAm
+						else a.geaendertAm end as mutationAm
+				from Element a
+				where a.erstelltDurch = :variable
+				order by mutationAm desc nulls first
+				""");
+
+		assertQuery("""
+				select a,
+					case
+						when a.geaendertAm is null then a.erstelltAm
+						else a.geaendertAm end as mutationAm
+				from Element a
+				where a.erstelltDurch = :variable
+				order by mutationAm desc nulls last
+				""");
 	}
 
 	@Test // GH-2964
 	void roundFunctionShouldWorkLikeAnyOtherFunction() {
 
-		assertThatNoException().isThrownBy(() -> {
-			parseWithoutChanges("""
-					select round(count(ri) * 100 / max(ri.receipt.positions), 0) as perc
-					from StockOrderItem oi
-					right join StockReceiptItem ri
-					on ri.article = oi.article
-					""");
-		});
+		assertQuery("""
+				select round(count(ri) * 100 / max(ri.receipt.positions), 0) as perc
+				from StockOrderItem oi
+				right join StockReceiptItem ri
+				on ri.article = oi.article
+				""");
 	}
 
 	@Test // GH-3711
@@ -1854,6 +2235,42 @@ void powerShouldBeLegalInAQuery() {
 		assertQuery("select e.power.id from MyEntity e");
 	}
 
+	@Test // GH-3136
+	void doublePipeShouldBeValidAsAStringConcatOperator() {
+
+		assertQuery("""
+				select e.name || ' ' || e.title
+				from Employee e
+				""");
+	}
+
+	@Test // GH-3136
+	void additionalStringOperationsShouldWork() {
+
+		assertQuery("""
+				select
+					replace(e.name, 'Baggins', 'Proudfeet'),
+					left(e.role, 4),
+					right(e.home, 5),
+					cast(e.distance_from_home, int)
+				from Employee e
+				""");
+	}
+
+	@Test // GH-3136
+	void combinedSelectStatementsShouldWork() {
+
+		assertQuery("""
+				select e from Employee e where e.last_name = 'Baggins'
+				intersect
+				select e from Employee e where e.first_name = 'Samwise'
+				union
+				select e from Employee e where e.home = 'The Shire'
+				except
+				select e from Employee e where e.home = 'Isengard'
+				""");
+	}
+
 	@Test // GH-3219
 	void extractFunctionShouldSupportAdditionalExtensions() {
 
@@ -1924,6 +2341,18 @@ void entityNameWithPackageContainingReservedWord(String reservedWord) {
 		assertQuery(source);
 	}
 
+	@ParameterizedTest // GH-3136
+	@ValueSource(strings = { "LEFT", "RIGHT" })
+	void leftRightStringFunctions(String keyword) {
+		assertQuery("SELECT %s(e.name, 3) FROM Employee e".formatted(keyword));
+	}
+
+	@Test // GH-3136
+	void replaceStringFunctions() {
+		assertQuery("SELECT REPLACE(e.name, 'o', 'a') FROM Employee e");
+		assertQuery("SELECT REPLACE(e.name, ' ', '_') FROM Employee e");
+	}
+
 	@Test
 	void reservedWordsShouldWork() {
 
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlQueryTransformerTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlQueryTransformerTests.java
index 40a8c4dc7a..cd2c3987fc 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlQueryTransformerTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlQueryTransformerTests.java
@@ -22,6 +22,7 @@
 import java.util.stream.Stream;
 
 import org.assertj.core.api.SoftAssertions;
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -32,7 +33,8 @@
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Sort.Order;
 import org.springframework.data.jpa.domain.JpaSort;
-import org.springframework.lang.Nullable;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.query.ReturnedType;
 import org.springframework.util.StringUtils;
 
 /**
@@ -280,7 +282,9 @@ void applySortingAccountsForNewlinesInSubselect() {
 				where exists (select u2
 				from user u2
 				)
-				""").applySorting(sort)).isEqualToIgnoringWhitespace("""
+				""").rewrite(new DefaultQueryRewriteInformation(sort,
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()))))
+				.isEqualToIgnoringWhitespace("""
 				select u
 				from user u
 				where exists (select u2
@@ -1172,7 +1176,8 @@ private void assertCountQuery(String originalQuery, String countQuery) {
 	}
 
 	private String createQueryFor(String query, Sort sort) {
-		return newParser(query).applySorting(sort);
+		return newParser(query).rewrite(new DefaultQueryRewriteInformation(sort,
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())));
 	}
 
 	private String createCountQueryFor(String query) {
@@ -1183,8 +1188,7 @@ private String createCountQueryFor(String query, @Nullable String countProjectio
 		return newParser(query).createCountQueryFor(countProjection);
 	}
 
-	@Nullable
-	private String alias(String query) {
+	private @Nullable String alias(String query) {
 		return newParser(query).detectAlias();
 	}
 
@@ -1197,6 +1201,6 @@ private String projection(String query) {
 	}
 
 	private QueryEnhancer newParser(String query) {
-		return JpaQueryEnhancer.forHql(DeclaredQuery.of(query, false));
+		return JpaQueryEnhancer.forHql(query);
 	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlSpecificationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlSpecificationTests.java
deleted file mode 100644
index be05e3fceb..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlSpecificationTests.java
+++ /dev/null
@@ -1,1788 +0,0 @@
-/*
- * Copyright 2022-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.springframework.data.jpa.repository.query;
-
-import static org.assertj.core.api.Assertions.*;
-
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.ValueSource;
-
-import org.springframework.data.jpa.repository.query.QueryRenderer.TokenRenderer;
-
-/**
- * Tests built around examples of HQL found in
- * https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc and
- * https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#query-language<br/>
- * <br/>
- * IMPORTANT: Purely verifies the parser without any transformations.
- *
- * @author Greg Turnquist
- * @author Mark Paluch
- * @author Christoph Strobl
- * @since 3.1
- */
-class HqlSpecificationTests {
-
-	private static final String SPEC_FAULT = "Disabled due to spec fault> ";
-
-	private static String parseWithoutChanges(String query) {
-
-		JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser.parseQuery(query);
-
-		return TokenRenderer.render(new HqlQueryRenderer().visit(parser.getContext()));
-	}
-
-	private void assertQuery(String query) {
-
-		String slimmedDownQuery = reduceWhitespace(query);
-		assertThat(parseWithoutChanges(slimmedDownQuery)).isEqualTo(slimmedDownQuery);
-	}
-
-	private String reduceWhitespace(String original) {
-
-		return original //
-				.replaceAll("[ \\t\\n]{1,}", " ") //
-				.trim();
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
-	 */
-	@Test
-	void joinExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order AS o JOIN o.lineItems AS l
-				WHERE l.shipped = FALSE
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#identification-variables
-	 */
-	@Test
-	void joinExample2() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l JOIN l.product p
-				WHERE p.productType = 'office_supplies'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#range-variable-declarations
-	 */
-	@Test
-	void rangeVariableDeclarations() {
-
-		assertQuery("""
-				SELECT DISTINCT o1
-				FROM Order o1, Order o2
-				WHERE o1.quantity > o2.quantity AND
-				 o2.customer.lastname = 'Smith' AND
-				 o2.customer.firstname = 'John'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample1() {
-
-		assertQuery("""
-				SELECT i.name, VALUE(p)
-				FROM Item i JOIN i.photos p
-				WHERE KEY(p) LIKE '%egret'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample2() {
-
-		assertQuery("""
-				SELECT i.name, p
-				FROM Item i JOIN i.photos p
-				WHERE KEY(p) LIKE '%egret'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample3() {
-
-		assertQuery("""
-				SELECT p.vendor
-				FROM Employee e JOIN e.contactInfo.phones p
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample4() {
-
-		assertQuery("""
-				SELECT p.vendor
-				FROM Employee e JOIN e.contactInfo c JOIN c.phones p
-				WHERE e.contactInfo.address.zipcode = '95054'
-				""");
-	}
-
-	@Test
-	void pathExpressionSyntaxExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT l.product
-				FROM Order AS o JOIN o.lineItems l
-				""");
-	}
-
-	@Test
-	void joinsExample1() {
-
-		assertQuery("""
-				SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize
-				""");
-	}
-
-	@Test
-	void joinsExample2() {
-
-		assertQuery("""
-				SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1
-				""");
-	}
-
-	@Test
-	void joinsInnerExample() {
-
-		assertQuery("""
-				SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1
-				""");
-	}
-
-	@Test
-	void joinsInExample() {
-
-		assertQuery("""
-				SELECT OBJECT(c) FROM Customer c , IN(c.orders) o WHERE c.status = 1
-				""");
-	}
-
-	@Test
-	void doubleJoinExample() {
-
-		assertQuery("""
-				SELECT p.vendor
-				FROM Employee e JOIN e.contactInfo c JOIN c.phones p
-				WHERE c.address.zipcode = '95054'
-				""");
-	}
-
-	@Test
-	void leftJoinExample() {
-
-		assertQuery("""
-				SELECT s.name, COUNT(p)
-				FROM Suppliers s LEFT JOIN s.products p
-				GROUP BY s.name
-				""");
-	}
-
-	@Test
-	void leftJoinOnExample() {
-
-		assertQuery("""
-				SELECT s.name, COUNT(p)
-				FROM Suppliers s LEFT JOIN s.products p
-				    ON p.status = 'inStock'
-				GROUP BY s.name
-				""");
-	}
-
-	@Test
-	void leftJoinWhereExample() {
-
-		assertQuery("""
-				SELECT s.name, COUNT(p)
-				FROM Suppliers s LEFT JOIN s.products p
-				WHERE p.status = 'inStock'
-				GROUP BY s.name
-				""");
-	}
-
-	@Test
-	void leftJoinFetchExample() {
-
-		assertQuery("""
-				SELECT d
-				FROM Department d LEFT JOIN FETCH d.employees
-				WHERE d.deptno = 1
-				""");
-	}
-
-	@Test
-	void collectionMemberExample() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				WHERE l.product.productType = 'office_supplies'
-				""");
-	}
-
-	@Test
-	void collectionMemberInExample() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o , IN(o.lineItems) l
-				WHERE l.product.productType = 'office_supplies'
-				""");
-	}
-
-	@Test
-	void fromClauseExample() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order AS o JOIN o.lineItems l JOIN l.product p
-				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample1() {
-
-		assertQuery("""
-				SELECT b.name, b.ISBN
-				FROM Order o JOIN TREAT(o.product AS Book) b
-				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample2() {
-
-		assertQuery("""
-				SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp
-				WHERE lp.budget > 1000
-				""");
-	}
-
-	/**
-	 * @see #fromClauseDowncastingExample3fixed()
-	 */
-	@Test
-	@Disabled(SPEC_FAULT + "Use double-quotes when it should be using single-quotes for a string literal")
-	void fromClauseDowncastingExample3_SPEC_BUG() {
-
-		assertQuery("""
-				SELECT e FROM Employee e JOIN e.projects p
-				WHERE TREAT(p AS LargeProject).budget > 1000
-				    OR TREAT(p AS SmallProject).name LIKE 'Persist%'
-				    OR p.description LIKE "cost overrun"
-				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample3fixed() {
-
-		assertQuery("""
-				SELECT e FROM Employee e JOIN e.projects p
-				WHERE TREAT(p AS LargeProject).budget > 1000
-				    OR TREAT(p AS SmallProject).name LIKE 'Persist%'
-				    OR p.description LIKE 'cost overrun'
-				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample4() {
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE TREAT(e AS Exempt).vacationDays > 10
-				    OR TREAT(e AS Contractor).hours > 100
-				""");
-	}
-
-	@ParameterizedTest // GH-3689
-	@ValueSource(strings = { "RESPECT NULLS", "IGNORE NULLS" })
-	void generic(String nullHandling) {
-
-		// not in the official documentation but supported in the grammar.
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE FOO(x).bar %s
-				""".formatted(nullHandling));
-	}
-
-	@Test // GH-3689
-	void size() {
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE SIZE(x) > 1
-				""");
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE SIZE(e.skills) > 1
-				""");
-	}
-
-	@Test // GH-3689
-	void collectionAggregate() {
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE MAXELEMENT(foo) > MINELEMENT(bar)
-				""");
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE MININDEX(foo) > MAXINDEX(bar)
-				""");
-	}
-
-	@Test // GH-3689
-	void trunc() {
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE TRUNC(x) = TRUNCATE(y)
-				""");
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE TRUNC(e, 'foo') = TRUNCATE(e, 'bar')
-				""");
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE TRUNC(e, 'YEAR') = TRUNCATE(LOCAL DATETIME, 'YEAR')
-				""");
-	}
-
-	@ParameterizedTest // GH-3689
-	@ValueSource(strings = { "YEAR", "MONTH", "DAY", "WEEK", "QUARTER", "HOUR", "MINUTE", "SECOND", "NANOSECOND",
-			"NANOSECOND", "EPOCH" })
-	void trunc(String truncation) {
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE TRUNC(e, %1$s) = TRUNCATE(e, %1$s)
-				""".formatted(truncation));
-	}
-
-	@Test // GH-3689
-	void format() {
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE FORMAT(x AS 'yyyy') = FORMAT(e.hiringDate AS 'yyyy')
-				""");
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE e.hiringDate = format(LOCAL DATETIME as 'yyyy-MM-dd')
-				""");
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE e.hiringDate = format(LOCAL_DATE() as 'yyyy-MM-dd')
-				""");
-	}
-
-	@Test // GH-3689
-	void collate() {
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE COLLATE(x AS ucs_basic) = COLLATE(e.name AS ucs_basic)
-				""");
-	}
-
-	@Test // GH-3689
-	void substring() {
-
-		assertQuery("select substring(c.number, 1, 2) " + //
-				"from Call c");
-
-		assertQuery("select substring(c.number, 1) " + //
-				"from Call c");
-
-		assertQuery("select substring(c.number, 1, position('/0' in c.number)) " + //
-				"from Call c");
-
-		assertQuery("select substring(c.number FROM 1 FOR 2) " + //
-				"from Call c");
-
-		assertQuery("select substring(c.number FROM 1) " + //
-				"from Call c");
-
-		assertQuery("select substring(c.number FROM 1 FOR position('/0' in c.number)) " + //
-				"from Call c");
-
-		assertQuery("select substring(c.number FROM 1) AS shortNumber " + //
-				"from Call c");
-	}
-
-	@Test // GH-3689
-	void overlay() {
-
-		assertQuery("select OVERLAY(c.number PLACING 1 FROM 2) " + //
-				"from Call c ");
-
-		assertQuery("select OVERLAY(p.number PLACING 1 FROM 2 FOR 3) " + //
-				"from Call c ");
-	}
-
-	@Test // GH-3689
-	void pad() {
-
-		assertQuery("select PAD(c.number WITH 1 LEADING) " + //
-				"from Call c ");
-
-		assertQuery("select PAD(c.number WITH 1 TRAILING) " + //
-				"from Call c ");
-
-		assertQuery("select PAD(c.number WITH 1 LEADING '0') " + //
-				"from Call c ");
-
-		assertQuery("select PAD(c.number WITH 1 TRAILING '0') " + //
-				"from Call c ");
-	}
-
-	@Test // GH-3689
-	void position() {
-
-		assertQuery("select POSITION(c.number IN 'foo') " + //
-				"from Call c ");
-
-		assertQuery("select POSITION(c.number IN 'foo') + 1 AS pos " + //
-				"from Call c ");
-	}
-
-	@Test // GH-3689
-	void currentDateFunctions() {
-
-		assertQuery("select CURRENT DATE, CURRENT_DATE() " + //
-				"from Call c ");
-
-		assertQuery("select CURRENT TIME, CURRENT_TIME() " + //
-				"from Call c ");
-
-		assertQuery("select CURRENT TIMESTAMP, CURRENT_TIMESTAMP() " + //
-				"from Call c ");
-
-		assertQuery("select INSTANT, CURRENT_INSTANT() " + //
-				"from Call c ");
-
-		assertQuery("select LOCAL DATE, LOCAL_DATE() " + //
-				"from Call c ");
-
-		assertQuery("select LOCAL TIME, LOCAL_TIME() " + //
-				"from Call c ");
-
-		assertQuery("select LOCAL DATETIME, LOCAL_DATETIME() " + //
-				"from Call c ");
-
-		assertQuery("select OFFSET DATETIME, OFFSET_DATETIME() " + //
-				"from Call c ");
-
-		assertQuery("select OFFSET DATETIME AS offsetDatetime, OFFSET_DATETIME() AS offset_datetime " + //
-				"from Call c ");
-	}
-
-	@Test // GH-3689
-	void cube() {
-
-		assertQuery("select CUBE(foo), CUBE(foo, bar) " + //
-				"from Call c ");
-
-		assertQuery("select c.callerId from Call c GROUP BY CUBE(state, province)");
-	}
-
-	@Test // GH-3689
-	void rollup() {
-
-		assertQuery("select ROLLUP(foo), ROLLUP(foo, bar) " + //
-				"from Call c ");
-
-		assertQuery("select c.callerId from Call c GROUP BY ROLLUP(state, province)");
-	}
-
-	@Test
-	void pathExpressionsNamedParametersExample() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.status = :stat
-				""");
-	}
-
-	@Test
-	void betweenExpressionsExample() {
-
-		assertQuery("""
-				SELECT t
-				FROM CreditCard c JOIN c.transactionHistory t
-				WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9
-				""");
-	}
-
-	@Test
-	void isEmptyExample() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS EMPTY
-				""");
-	}
-
-	@Test
-	void memberOfExample() {
-
-		assertQuery("""
-				SELECT p
-				FROM Person p
-				WHERE 'Joe' MEMBER OF p.nicknames
-				""");
-	}
-
-	@Test
-	void existsSubSelectExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE EXISTS (SELECT spouseEmp
-				    FROM Employee spouseEmp
-				        WHERE spouseEmp = emp.spouse)
-				""");
-	}
-
-	@Test // GH-3689
-	void everyAll() {
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE EVERY (SELECT spouseEmp
-				    FROM Employee spouseEmp) > 1
-				""");
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE ALL (SELECT spouseEmp
-				    FROM Employee spouseEmp) > 1
-				""");
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE ALL (foo > 1) OVER (PARTITION BY bar)
-				""");
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE ALL VALUES (foo) > 1
-				""");
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE ALL ELEMENTS (foo) > 1
-				""");
-	}
-
-	@Test // GH-3689
-	void anySome() {
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE ANY (SELECT spouseEmp
-				    FROM Employee spouseEmp) > 1
-				""");
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE SOME (SELECT spouseEmp
-				    FROM Employee spouseEmp) > 1
-				""");
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE ANY (foo > 1) OVER (PARTITION BY bar)
-				""");
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE ANY VALUES (foo) > 1
-				""");
-	}
-
-	@Test // GH-3689
-	void listAgg() {
-
-		assertQuery("select listagg(p.number, ', ') within group (order by p.type, p.number) " + //
-				"from Phone p " + //
-				"group by p.person");
-	}
-
-	@Test
-	void allExample() {
-
-		assertQuery("""
-				SELECT emp
-				FROM Employee emp
-				WHERE emp.salary > ALL (SELECT m.salary
-				    FROM Manager m
-				    WHERE m.department = emp.department)
-				""");
-	}
-
-	@Test
-	void existsSubSelectExample2() {
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE EXISTS (SELECT spouseEmp
-				    FROM Employee spouseEmp
-				    WHERE spouseEmp = emp.spouse)
-				""");
-	}
-
-	@Test
-	void subselectNumericComparisonExample1() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE (SELECT AVG(o.price) FROM c.orders o) > 100
-				""");
-	}
-
-	@Test
-	void subselectNumericComparisonExample2() {
-
-		assertQuery("""
-				SELECT goodCustomer
-				FROM Customer goodCustomer
-				WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed) / 2.0 FROM Customer c)
-				""");
-	}
-
-	@Test
-	void indexExample() {
-
-		assertQuery("""
-				SELECT w.name
-				FROM Course c JOIN c.studentWaitlist w
-				WHERE c.name = 'Calculus'
-				AND INDEX(w) = 0
-				""");
-	}
-
-	/**
-	 * @see #functionInvocationExampleWithCorrection()
-	 */
-	@Test
-	void functionInvocationExampleAsBooleanExpression() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit)
-				""");
-	}
-
-	@Test
-	void functionInvocationExampleWithCorrection() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE
-				""");
-	}
-
-	@ParameterizedTest // GH-3628
-	@ValueSource(strings = { "is true", "is not true", "is false", "is not false" })
-	void functionInvocationWithIsBoolean(String booleanComparison) {
-
-		assertQuery("""
-				from RoleTmpl where find_in_set(:appId, appIds) %s
-				""".formatted(booleanComparison));
-	}
-
-	@Test
-	void updateCaseExample1() {
-
-		assertQuery("""
-				UPDATE Employee e
-				SET e.salary =
-				    CASE WHEN e.rating = 1 THEN e.salary * 1.1
-				         WHEN e.rating = 2 THEN e.salary * 1.05
-				         ELSE e.salary * 1.01
-				    END
-				""");
-	}
-
-	@Test
-	void updateCaseExample2() {
-
-		assertQuery("""
-				UPDATE Employee e
-				SET e.salary =
-				    CASE e.rating WHEN 1 THEN e.salary * 1.1
-				                  WHEN 2 THEN e.salary * 1.05
-				                  ELSE e.salary * 1.01
-				    END
-				""");
-	}
-
-	@Test
-	void selectCaseExample1() {
-
-		assertQuery("""
-				SELECT e.name,
-				    CASE TYPE(e) WHEN Exempt THEN 'Exempt'
-				                 WHEN Contractor THEN 'Contractor'
-				                 WHEN Intern THEN 'Intern'
-				                 ELSE 'NonExempt'
-				    END
-				FROM Employee e
-				WHERE e.dept.name = 'Engineering'
-				""");
-	}
-
-	@Test
-	void selectCaseExample2() {
-
-		assertQuery("""
-				SELECT e.name,
-				       f.name,
-				       CONCAT(CASE WHEN f.annualMiles > 50000 THEN 'Platinum '
-				                   WHEN f.annualMiles > 25000 THEN 'Gold '
-				                   ELSE ''
-				              END,
-				       'Frequent Flyer')
-				FROM Employee e JOIN e.frequentFlierPlan f
-				""");
-	}
-
-	@Test
-	void theRest() {
-
-		assertQuery("""
-				SELECT e
-				 FROM Employee e
-				 WHERE TYPE(e) IN (Exempt, Contractor)
-				""");
-	}
-
-	@Test
-	void theRest2() {
-
-		assertQuery("""
-				SELECT e
-				    FROM Employee e
-				    WHERE TYPE(e) IN (:empType1, :empType2)
-				""");
-	}
-
-	@Test
-	void theRest3() {
-
-		assertQuery("""
-				SELECT e
-				FROM Employee e
-				WHERE TYPE(e) IN :empTypes
-				""");
-	}
-
-	@Test
-	void theRest4() {
-
-		assertQuery("""
-				SELECT TYPE(e)
-				FROM Employee e
-				WHERE TYPE(e) <> Exempt
-				""");
-	}
-
-	@Test
-	void theRest5() {
-
-		assertQuery("""
-				SELECT c.status, AVG(c.filledOrderCount), COUNT(c)
-				FROM Customer c
-				GROUP BY c.status
-				HAVING c.status IN (1, 2)
-				""");
-	}
-
-	@Test
-	void theRest6() {
-
-		assertQuery("""
-				SELECT c.country, COUNT(c)
-				FROM Customer c
-				GROUP BY c.country
-				HAVING COUNT(c) > 30
-				""");
-	}
-
-	@Test
-	void theRest7() {
-
-		assertQuery("""
-				SELECT c, COUNT(o)
-				FROM Customer c JOIN c.orders o
-				GROUP BY c
-				HAVING COUNT(o) >= 5
-				""");
-	}
-
-	@Test
-	void theRest8() {
-
-		assertQuery("""
-				SELECT c.id, c.status
-				FROM Customer c JOIN c.orders o
-				WHERE o.count > 100
-				""");
-	}
-
-	@Test
-	void theRest9() {
-
-		assertQuery("""
-				SELECT v.location.street, KEY(i).title, VALUE(i)
-				FROM VideoStore v JOIN v.videoInventory i
-				WHERE v.location.zipcode = '94301' AND VALUE(i) > 0
-				""");
-	}
-
-	@Test
-	void theRest10() {
-
-		assertQuery("""
-				SELECT o.lineItems FROM Order AS o
-				""");
-	}
-
-	@Test
-	void theRest11() {
-
-		assertQuery("""
-				SELECT c, COUNT(l) AS itemCount
-				FROM Customer c JOIN c.Orders o JOIN o.lineItems l
-				WHERE c.address.state = 'CA'
-				GROUP BY c
-				ORDER BY itemCount
-				""");
-	}
-
-	@Test
-	void theRest12() {
-
-		assertQuery("""
-				SELECT NEW com.acme.example.CustomerDetails(c.id, c.status, o.count)
-				FROM Customer c JOIN c.orders o
-				WHERE o.count > 100
-				""");
-	}
-
-	@Test
-	void theRest13() {
-
-		assertQuery("""
-				SELECT e.address AS addr
-				FROM Employee e
-				""");
-	}
-
-	@Test
-	void theRest14() {
-
-		assertQuery("""
-				SELECT AVG(o.quantity) FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest15() {
-
-		assertQuery("""
-				SELECT SUM(l.price)
-				FROM Order o JOIN o.lineItems l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				""");
-	}
-
-	@Test
-	void theRest16() {
-
-		assertQuery("""
-				SELECT COUNT(o) FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest17() {
-
-		assertQuery("""
-				SELECT COUNT(l.price)
-				FROM Order o JOIN o.lineItems l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				""");
-	}
-
-	@Test
-	void theRest18() {
-
-		assertQuery("""
-				SELECT COUNT(l)
-				FROM Order o JOIN o.lineItems l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John' AND l.price IS NOT NULL
-				""");
-	}
-
-	@Test
-	void theRest19() {
-
-		assertQuery("""
-				SELECT o
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA'
-				ORDER BY o.quantity DESC, o.totalcost
-				""");
-	}
-
-	@Test
-	void theRest20() {
-
-		assertQuery("""
-				SELECT o.quantity, a.zipcode
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA'
-				ORDER BY o.quantity, a.zipcode
-				""");
-	}
-
-	@Test
-	void theRest21() {
-
-		assertQuery("""
-				SELECT o.quantity, o.cost * 1.08 AS taxedCost, a.zipcode
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA' AND a.county = 'Santa Clara'
-				ORDER BY o.quantity, taxedCost, a.zipcode
-				""");
-	}
-
-	@Test
-	void theRest22() {
-
-		assertQuery("""
-				SELECT AVG(o.quantity) as q, a.zipcode
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA'
-				GROUP BY a.zipcode
-				ORDER BY q DESC
-				""");
-	}
-
-	@Test
-	void theRest23() {
-
-		assertQuery("""
-				SELECT p.product_name
-				FROM Order o JOIN o.lineItems l JOIN l.product p JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				ORDER BY p.price
-				""");
-	}
-
-	/**
-	 * This query is specifically dubbed illegal in the spec, but apparently works with Hibernate.
-	 */
-	@Test
-	void theRest24() {
-
-		assertQuery("""
-				SELECT p.product_name
-				FROM Order o , IN(o.lineItems) l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				ORDER BY o.quantity
-				""");
-	}
-
-	@Test
-	void theRest25() {
-
-		assertQuery("""
-				DELETE
-				FROM Customer c
-				WHERE c.status = 'inactive'
-				""");
-	}
-
-	@Test
-	void collectionIsEmpty() {
-
-		assertQuery("""
-				DELETE
-				FROM Customer c
-				WHERE c.status = 'inactive'
-				AND c.orders IS EMPTY
-				""");
-
-		assertQuery("""
-				DELETE
-				FROM Customer c
-				WHERE c.status = 'inactive'
-				AND c.orders IS NOT EMPTY
-				""");
-	}
-
-	@Test // GH-3628
-	void booleanPredicate() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.orders IS TRUE
-				""");
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.orders IS NOT TRUE
-				""");
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.orders IS FALSE
-				""");
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.orders IS NOT FALSE
-				""");
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.orders IS NULL
-				""");
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.orders IS NOT NULL
-				""");
-	}
-
-	@ParameterizedTest // GH-3628
-	@ValueSource(strings = { "IS DISTINCT FROM", "IS NOT DISTINCT FROM" })
-	void distinctFromPredicate(String distinctFrom) {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.orders %s c.payments
-				""".formatted(distinctFrom));
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.orders %s c.payments
-				""".formatted(distinctFrom));
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				GROUP BY c.lastname
-				HAVING c.orders %s c.payments
-				""".formatted(distinctFrom));
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE EXISTS (SELECT c2
-				    FROM Customer c2
-				        WHERE c2.orders %s c.orders)
-				""".formatted(distinctFrom));
-	}
-
-	@Test
-	void theRest27() {
-
-		assertQuery("""
-				UPDATE Customer c
-				SET c.status = 'outstanding'
-				WHERE c.balance < 10000
-				""");
-	}
-
-	@Test
-	void theRest28() {
-
-		assertQuery("""
-				UPDATE Employee e
-				SET e.address.building = 22
-				WHERE e.address.building = 14
-				AND e.address.city = 'Santa Clara'
-				AND e.project = 'Jakarta EE'
-				""");
-	}
-
-	@Test
-	void theRest29() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest30() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.shippingAddress.state = 'CA'
-				""");
-	}
-
-	@Test
-	void theRest31() {
-
-		assertQuery("""
-				SELECT DISTINCT o.shippingAddress.state
-				FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest32() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				""");
-	}
-
-	@Test
-	void theRest33() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS NOT EMPTY
-				""");
-	}
-
-	@Test
-	void theRest34() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS EMPTY
-				""");
-	}
-
-	@Test
-	void theRest35() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				WHERE l.shipped = FALSE
-				""");
-	}
-
-	@Test
-	void theRest36() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE
-				NOT (o.shippingAddress.state = o.billingAddress.state AND
-				o.shippingAddress.city = o.billingAddress.city AND
-				o.shippingAddress.street = o.billingAddress.street)
-				""");
-	}
-
-	@Test
-	void theRest37() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.shippingAddress <> o.billingAddress
-				""");
-	}
-
-	@Test
-	void theRest38() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				WHERE l.product.name = ?1
-				""");
-	}
-
-	@Test // GH-3689
-	void insertQueries() {
-
-		assertQuery("insert Person (id, name) values (100L, 'Jane Doe')");
-
-		assertQuery("insert Person (id, name) values " + //
-				"(101L, 'J A Doe III'), " + //
-				"(102L, 'J X Doe'), " + //
-				"(103L, 'John Doe, Jr')");
-
-		assertQuery("insert into Partner (id, name) " + //
-				"select p.id, p.name from Person p ");
-
-		assertQuery("INSERT INTO AggregationPrice (range, price, type) " + "VALUES (:range, :price, :priceType) "
-				+ "ON CONFLICT (range) DO UPDATE  SET price = :price, type = :priceType");
-
-		assertQuery("INSERT INTO AggregationPrice (range, price, type) " + "VALUES (:range, :price, :priceType) "
-				+ "ON CONFLICT ON CONSTRAINT foo DO UPDATE  SET price = :price, type = :priceType");
-
-		assertQuery("INSERT INTO AggregationPrice (range, price, type) " + "VALUES (:range, :price, :priceType) "
-				+ "ON CONFLICT ON CONSTRAINT foo DO NOTHING");
-	}
-
-	@Test
-	void hqlQueries() {
-
-		assertQuery("from Person");
-		assertQuery("select local datetime");
-		assertQuery("from Person p select p.name");
-		assertQuery("update Person set nickName = 'Nacho' " + //
-				"where name = 'Ignacio'");
-		assertQuery("update Person p " + //
-				"set p.name = :newName " + //
-				"where p.name = :oldName");
-		assertQuery("update Person " + //
-				"set name = :newName " + //
-				"where name = :oldName");
-		assertQuery("update versioned Person " + //
-				"set name = :newName " + //
-				"where name = :oldName");
-
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.name like 'Joe'");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.name like 'Joe''s'");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.id = 1");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.id = 1L");
-		assertQuery("select c " + //
-				"from Call c " + //
-				"where c.duration > 100.5");
-		assertQuery("select c " + //
-				"from Call c " + //
-				"where c.duration > 100.5F");
-		assertQuery("select c " + //
-				"from Call c " + //
-				"where c.duration > 1e+2");
-		assertQuery("select c " + //
-				"from Call c " + //
-				"where c.duration > 1e+2F");
-		assertQuery("from Phone ph " + //
-				"where ph.type = LAND_LINE");
-		assertQuery("select java.lang.Math.PI");
-		assertQuery("select 'Customer ' || p.name " + //
-				"from Person p " + //
-				"where p.id = 1");
-		assertQuery("select sum(ch.duration) * :multiplier " + //
-				"from Person pr " + //
-				"join pr.phones ph " + //
-				"join ph.callHistory ch " + //
-				"where ph.id = 1L ");
-		assertQuery("select year(local date) - year(p.createdOn) " + //
-				"from Person p " + //
-				"where p.id = 1L");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where year(local date) - year(p.createdOn) > 1");
-		assertQuery("select " + //
-				"	case p.nickName " + //
-				"	when 'NA' " + //
-				"	then '<no nick name>' " + //
-				"	else p.nickName " + //
-				"	end " + //
-				"from Person p");
-		assertQuery("select " + //
-				"	case " + //
-				"	when p.nickName is null " + //
-				"	then " + //
-				"		case " + //
-				"		when p.name is null " + //
-				"		then '<no nick name>' " + //
-				"		else p.name " + //
-				"		end" + //
-				"	else p.nickName " + //
-				"	end " + //
-				"from Person p");
-		assertQuery("select " + //
-				"	case when p.nickName is null " + //
-				"		 then p.id * 1000 " + //
-				"		 else p.id " + //
-				"	end " + //
-				"from Person p " + //
-				"order by p.id");
-		assertQuery("select p " + //
-				"from Payment p " + //
-				"where type(p) = CreditCardPayment");
-		assertQuery("select p " + //
-				"from Payment p " + //
-				"where type(p) = :type");
-		assertQuery("select p " + //
-				"from Payment p " + //
-				"where length(treat(p as CreditCardPayment).cardNumber) between 16 and 20");
-		assertQuery("select nullif(p.nickName, p.name) " + //
-				"from Person p");
-		assertQuery("select " + //
-				"	case" + //
-				"	when p.nickName = p.name" + //
-				"	then null" + //
-				"	else p.nickName" + //
-				"	end " + //
-				"from Person p");
-		assertQuery("select coalesce(p.nickName, '<no nick name>') " + //
-				"from Person p");
-		assertQuery("select coalesce(p.nickName, p.name, '<no nick name>') " + //
-				"from Person p");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where size(p.phones) >= 2");
-		assertQuery("select concat(p.number, ' : ', cast(c.duration as string)) " + //
-				"from Call c " + //
-				"join c.phone p");
-		assertQuery("select upper(p.name) " + //
-				"from Person p ");
-		assertQuery("select lower(p.name) " + //
-				"from Person p ");
-		assertQuery("select trim(p.name) " + //
-				"from Person p ");
-		assertQuery("select trim(leading ' ' from p.name) " + //
-				"from Person p ");
-		assertQuery("select length(p.name) " + //
-				"from Person p ");
-		assertQuery("select locate('John', p.name) " + //
-				"from Person p ");
-		assertQuery("select abs(c.duration) " + //
-				"from Call c ");
-		assertQuery("select mod(c.duration, 10) " + //
-				"from Call c ");
-		assertQuery("select sqrt(c.duration) " + //
-				"from Call c ");
-		assertQuery("select cast(c.duration as String) " + //
-				"from Call c ");
-		assertQuery("select str(c.timestamp) " + //
-				"from Call c ");
-		assertQuery("select str(cast(duration as float) / 60, 4, 2) " + //
-				"from Call c ");
-		assertQuery("select c " + //
-				"from Call c " + //
-				"where extract(date from c.timestamp) = local date");
-		assertQuery("select extract(year from c.timestamp) " + //
-				"from Call c ");
-		assertQuery("select year(c.timestamp) " + //
-				"from Call c ");
-		assertQuery("select var_samp(c.duration) as sampvar, var_pop(c.duration) as popvar " + //
-				"from Call c ");
-		assertQuery("select bit_length(c.phone.number) " + //
-				"from Call c ");
-		assertQuery("select c " + //
-				"from Call c " + //
-				"where c.duration < 30 ");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.name like 'John%' ");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.createdOn > '1950-01-01' ");
-		assertQuery("select p " + //
-				"from Phone p " + //
-				"where p.type = 'MOBILE' ");
-		assertQuery("select p " + //
-				"from Payment p " + //
-				"where p.completed = true ");
-		assertQuery("select p " + //
-				"from Payment p " + //
-				"where type(p) = WireTransferPayment ");
-		assertQuery("select p " + //
-				"from Payment p, Phone ph " + //
-				"where p.person = ph.person ");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"join p.phones ph " + //
-				"where p.id = 1L and index(ph) between 0 and 3");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.createdOn between '1999-01-01' and '2001-01-02'");
-		assertQuery("select c " + //
-				"from Call c " + //
-				"where c.duration between 5 and 20");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.name between 'H' and 'M'");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.nickName is not null");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.nickName is null");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.name like 'Jo%'");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.name not like 'Jo%'");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.name like 'Dr|_%' escape '|'");
-		assertQuery("select p " + //
-				"from Payment p " + //
-				"where type(p) in (CreditCardPayment, WireTransferPayment)");
-		assertQuery("select p " + //
-				"from Phone p " + //
-				"where type in ('MOBILE', 'LAND_LINE')");
-		assertQuery("select p " + //
-				"from Phone p " + //
-				"where type in :types");
-		assertQuery("select distinct p " + //
-				"from Phone p " + //
-				"where p.person.id in (select py.person.id " + //
-				"	from Payment py" + //
-				"	where py.completed = true and py.amount > 50)");
-		assertQuery("select distinct p " + //
-				"from Phone p " + //
-				"where p.person in (select py.person " + //
-				"	from Payment py" + //
-				"	where py.completed = true and py.amount > 50)");
-		assertQuery("select distinct p " + //
-				"from Payment p " + //
-				"where (p.amount, p.completed) in ((50, true)," + //
-				"	(100, true)," + //
-				"	(5, false))");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where 1 in indices (p.phones)");
-		assertQuery("select distinct p.person " + //
-				"from Phone p " + //
-				"join p.calls c " + //
-				"where 50 > all (select duration" + //
-				"	from Call" + //
-				"	where phone = p) ");
-		assertQuery("select p " + //
-				"from Phone p " + //
-				"where local date > all elements (p.repairTimestamps)");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where :phone = some elements (p.phones)");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where :phone member of p.phones");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where exists elements (p.phones)");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.phones is empty");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.phones is not empty");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.phones is not empty");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where 'Home address' member of p.addresses");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where 'Home address' not member of p.addresses");
-		assertQuery("select p " + //
-				"from Person p");
-		assertQuery("select p " + //
-				"from org.hibernate.userguide.model.Person p");
-		assertQuery("select distinct pr, ph " + //
-				"from Person pr, Phone ph " + //
-				"where ph.person = pr and ph is not null");
-		assertQuery("select distinct pr1 " + //
-				"from Person pr1, Person pr2 " + //
-				"where pr1.id <> pr2.id " + //
-				"  and pr1.address = pr2.address " + //
-				"  and pr1.createdOn < pr2.createdOn");
-		assertQuery("select distinct pr, ph " + //
-				"from Person pr cross join Phone ph " + //
-				"where ph.person = pr and ph is not null");
-		assertQuery("select p " + //
-				"from Payment p ");
-		assertQuery("select d.owner, d.payed " + //
-				"from (select p.person as owner, c.payment is not null as payed " + //
-				"  from Call c " + //
-				"  join c.phone p " + //
-				"  where p.number = :phoneNumber) d");
-		assertQuery("select distinct pr " + //
-				"from Person pr " + //
-				"join Phone ph on ph.person = pr " + //
-				"where ph.type = :phoneType");
-		assertQuery("select distinct pr " + //
-				"from Person pr " + //
-				"join pr.phones ph " + //
-				"where ph.type = :phoneType");
-		assertQuery("select distinct pr " + //
-				"from Person pr " + //
-				"inner join pr.phones ph " + //
-				"where ph.type = :phoneType");
-		assertQuery("select distinct pr " + //
-				"from Person pr " + //
-				"left join pr.phones ph " + //
-				"where ph is null " + //
-				"   or ph.type = :phoneType");
-		assertQuery("select distinct pr " + //
-				"from Person pr " + //
-				"left outer join pr.phones ph " + //
-				"where ph is null " + //
-				"   or ph.type = :phoneType");
-		assertQuery("select pr.name, ph.number " + //
-				"from Person pr " + //
-				"left join pr.phones ph with ph.type = :phoneType ");
-		assertQuery("select pr.name, ph.number " + //
-				"from Person pr " + //
-				"left join pr.phones ph on ph.type = :phoneType ");
-		assertQuery("select distinct pr " + //
-				"from Person pr " + //
-				"left join fetch pr.phones ");
-		assertQuery("select a, ccp " + //
-				"from Account a " + //
-				"join treat(a.payments as CreditCardPayment) ccp " + //
-				"where length(ccp.cardNumber) between 16 and 20");
-		assertQuery("select c, ccp " + //
-				"from Call c " + //
-				"join treat(c.payment as CreditCardPayment) ccp " + //
-				"where length(ccp.cardNumber) between 16 and 20");
-		assertQuery("select longest.duration " + //
-				"from Phone p " + //
-				"left join lateral (" + //
-				"select c.duration as duration " + //
-				"  from p.calls c" + //
-				"  order by c.duration desc" + //
-				"  limit 1 " + //
-				"  ) longest " + //
-				"where p.number = :phoneNumber");
-		assertQuery("select ph " + //
-				"from Phone ph " + //
-				"where ph.person.address = :address ");
-		assertQuery("select ph " + //
-				"from Phone ph " + //
-				"join ph.person pr " + //
-				"where pr.address = :address ");
-		assertQuery("select ph " + //
-				"from Phone ph " + //
-				"where ph.person.address = :address " + //
-				"  and ph.person.createdOn > :timestamp");
-		assertQuery("select ph " + //
-				"from Phone ph " + //
-				"inner join ph.person pr " + //
-				"where pr.address = :address " + //
-				"  and pr.createdOn > :timestamp");
-		assertQuery("select ph " + //
-				"from Person pr " + //
-				"join pr.phones ph " + //
-				"join ph.calls c " + //
-				"where pr.address = :address " + //
-				"  and c.duration > :duration");
-		assertQuery("select ch " + //
-				"from Phone ph " + //
-				"join ph.callHistory ch " + //
-				"where ph.id = :id ");
-		assertQuery("select value(ch) " + //
-				"from Phone ph " + //
-				"join ph.callHistory ch " + //
-				"where ph.id = :id ");
-		assertQuery("select key(ch) " + //
-				"from Phone ph " + //
-				"join ph.callHistory ch " + //
-				"where ph.id = :id ");
-		assertQuery("select key(ch) " + //
-				"from Phone ph " + //
-				"join ph.callHistory ch " + //
-				"where ph.id = :id ");
-		assertQuery("select entry (ch) " + //
-				"from Phone ph " + //
-				"join ph.callHistory ch " + //
-				"where ph.id = :id ");
-		assertQuery("select sum(ch.duration) " + //
-				"from Person pr " + //
-				"join pr.phones ph " + //
-				"join ph.callHistory ch " + //
-				"where ph.id = :id " + //
-				"  and index(ph) = :phoneIndex");
-		assertQuery("select value(ph.callHistory) " + //
-				"from Phone ph " + //
-				"where ph.id = :id ");
-		assertQuery("select key(ph.callHistory) " + //
-				"from Phone ph " + //
-				"where ph.id = :id ");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.phones[0].type = LAND_LINE");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where p.addresses['HOME'] = :address");
-		assertQuery("select pr " + //
-				"from Person pr " + //
-				"where pr.phones[max(indices(pr.phones))].type = 'LAND_LINE'");
-		assertQuery("select p.name, p.nickName " + //
-				"from Person p ");
-		assertQuery("select p.name as name, p.nickName as nickName " + //
-				"from Person p ");
-		assertQuery("select new org.hibernate.userguide.hql.CallStatistics(count(c), " + //
-				"	sum(c.duration), " + //
-				"	min(c.duration), " + //
-				"	max(c.duration), " + //
-				"	avg(c.duration)" + //
-				")  " + //
-				"from Call c ");
-		assertQuery("select new map(p.number as phoneNumber, " + //
-				"	sum(c.duration) as totalDuration, " + //
-				"	avg(c.duration) as averageDuration)  " + //
-				"from Call c " + //
-				"join c.phone p " + //
-				"group by p.number ");
-		assertQuery("select new list(p.number," + //
-				"	c.duration)  " + //
-				"from Call c " + //
-				"join c.phone p ");
-		assertQuery("select distinct p.lastName " + //
-				"from Person p");
-		assertQuery("select " + //
-				"	count(c), " + //
-				"	sum(c.duration), " + //
-				"	min(c.duration), " + //
-				"	max(c.duration), " + //
-				"	avg(c.duration)  " + //
-				"from Call c ");
-		assertQuery("select count(distinct c.phone) " + //
-				"from Call c ");
-		assertQuery("select p.number, count(c) " + //
-				"from Call c " + //
-				"join c.phone p " + //
-				"group by p.number");
-		assertQuery("select p " + //
-				"from Phone p " + //
-				"where max(elements(p.calls)) = :call");
-		assertQuery("select p " + //
-				"from Phone p " + //
-				"where min(elements(p.calls)) = :call");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"where max(indices(p.phones)) = 0");
-		assertQuery("select count(c) filter (where c.duration < 30) " + //
-				"from Call c ");
-		assertQuery("select p.number, count(c) filter (where c.duration < 30) " + //
-				"from Call c " + //
-				"join c.phone p " + //
-				"group by p.number");
-		assertQuery("select sum(c.duration) " + //
-				"from Call c ");
-		assertQuery("select p.name, sum(c.duration) " + //
-				"from Call c " + //
-				"join c.phone ph " + //
-				"join ph.person p " + //
-				"group by p.name");
-		assertQuery("select p, sum(c.duration) " + //
-				"from Call c " + //
-				"join c.phone ph " + //
-				"join ph.person p " + //
-				"group by p");
-		assertQuery("select p.name, sum(c.duration) " + //
-				"from Call c " + //
-				"join c.phone ph " + //
-				"join ph.person p " + //
-				"group by p.name " + //
-				"having sum(c.duration) > 1000");
-		assertQuery("select p.name from Person p " + //
-				"union " + //
-				"select p.nickName from Person p where p.nickName is not null");
-		assertQuery("select p " + //
-				"from Person p " + //
-				"order by p.name");
-		assertQuery("select p.name, sum(c.duration) as total " + //
-				"from Call c " + //
-				"join c.phone ph " + //
-				"join ph.person p " + //
-				"group by p.name " + //
-				"order by total");
-		assertQuery("select c " + //
-				"from Call c " + //
-				"join c.phone p " + //
-				"order by p.number " + //
-				"limit 50");
-		assertQuery("select c " + //
-				"from Call c " + //
-				"join c.phone p " + //
-				"order by p.number " + //
-				"fetch first 50 rows only");
-		assertQuery("select p " + //
-				"from Phone p " + //
-				"join fetch p.calls " + //
-				"order by p " + //
-				"limit 50");
-	}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerUnitTests.java
index dee9d10d66..126868890d 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerUnitTests.java
@@ -25,6 +25,8 @@
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 import org.springframework.data.domain.Sort;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.query.ReturnedType;
 
 /**
  * TCK Tests for {@link JSqlParserQueryEnhancer}.
@@ -34,19 +36,20 @@
  * @author Geoffrey Deremetz
  * @author Christoph Strobl
  */
-public class JSqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests {
+class JSqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests {
 
 	@Override
-	QueryEnhancer createQueryEnhancer(DeclaredQuery declaredQuery) {
-		return new JSqlParserQueryEnhancer(declaredQuery);
+	QueryEnhancer createQueryEnhancer(DeclaredQuery query) {
+		return new JSqlParserQueryEnhancer(query);
 	}
 
 	@Test // GH-3546
 	void shouldApplySorting() {
 
-		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.of("SELECT e FROM Employee e", true));
+		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.jpqlQuery("SELECT e FROM Employee e"));
 
-		String sql = enhancer.applySorting(Sort.by("foo", "bar"));
+		String sql = enhancer.rewrite(new DefaultQueryRewriteInformation(Sort.by("foo", "bar"),
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())));
 
 		assertThat(sql).isEqualTo("SELECT e FROM Employee e ORDER BY e.foo ASC, e.bar ASC");
 	}
@@ -54,15 +57,15 @@ void shouldApplySorting() {
 	@Test // GH-3707
 	void countQueriesShouldConsiderPrimaryTableAlias() {
 
-		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.of("""
+		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery("""
 				SELECT DISTINCT a.*, b.b1
 				FROM TableA a
 				  JOIN TableB b ON a.b = b.b
 				  LEFT JOIN TableC c ON b.c = c.c
 				ORDER BY b.b1, a.a1, a.a2
-				""", true));
+				"""));
 
-		String sql = enhancer.createCountQueryFor();
+		String sql = enhancer.createCountQueryFor(null);
 
 		assertThat(sql).startsWith("SELECT count(DISTINCT a.*) FROM TableA a");
 	}
@@ -82,16 +85,16 @@ void setOperationListWorks() {
 				+ "except \n" //
 				+ "select SOME_COLUMN from SOME_OTHER_TABLE where REPORTING_DATE = :REPORTING_DATE";
 
-		StringQuery stringQuery = new StringQuery(setQuery, true);
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery);
+		DefaultEntityQuery query = new TestEntityQuery(setQuery, true);
+		QueryEnhancer queryEnhancer = QueryEnhancer.create(query);
 
-		assertThat(stringQuery.getAlias()).isNullOrEmpty();
-		assertThat(stringQuery.getProjection()).isEqualToIgnoringCase("SOME_COLUMN");
-		assertThat(stringQuery.hasConstructorExpression()).isFalse();
+		assertThat(query.getAlias()).isNullOrEmpty();
+		assertThat(query.getProjection()).isEqualToIgnoringCase("SOME_COLUMN");
+		assertThat(query.hasConstructorExpression()).isFalse();
 
-		assertThat(queryEnhancer.createCountQueryFor()).isEqualToIgnoringCase(setQuery);
-		assertThat(queryEnhancer.applySorting(Sort.by("SOME_COLUMN"))).endsWith("ORDER BY SOME_COLUMN ASC");
-		assertThat(queryEnhancer.getJoinAliases()).isEmpty();
+		assertThat(queryEnhancer.createCountQueryFor(null)).isEqualToIgnoringCase(setQuery);
+		assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("SOME_COLUMN"))))
+				.endsWith("ORDER BY SOME_COLUMN ASC");
 		assertThat(queryEnhancer.detectAlias()).isNullOrEmpty();
 		assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase("SOME_COLUMN");
 		assertThat(queryEnhancer.hasConstructorExpression()).isFalse();
@@ -105,16 +108,16 @@ void complexSetOperationListWorks() {
 				+ "select SOME_COLUMN from SOME_OTHER_TABLE where REPORTING_DATE = :REPORTING_DATE \n" //
 				+ "union select SOME_COLUMN from SOME_OTHER_OTHER_TABLE";
 
-		StringQuery stringQuery = new StringQuery(setQuery, true);
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery);
+		DefaultEntityQuery query = new TestEntityQuery(setQuery, true);
+		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query);
 
-		assertThat(stringQuery.getAlias()).isNullOrEmpty();
-		assertThat(stringQuery.getProjection()).isEqualToIgnoringCase("SOME_COLUMN");
-		assertThat(stringQuery.hasConstructorExpression()).isFalse();
+		assertThat(query.getAlias()).isNullOrEmpty();
+		assertThat(query.getProjection()).isEqualToIgnoringCase("SOME_COLUMN");
+		assertThat(query.hasConstructorExpression()).isFalse();
 
-		assertThat(queryEnhancer.createCountQueryFor()).isEqualToIgnoringCase(setQuery);
-		assertThat(queryEnhancer.applySorting(Sort.by("SOME_COLUMN").ascending())).endsWith("ORDER BY SOME_COLUMN ASC");
-		assertThat(queryEnhancer.getJoinAliases()).isEmpty();
+		assertThat(queryEnhancer.createCountQueryFor(null)).isEqualToIgnoringCase(setQuery);
+		assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("SOME_COLUMN").ascending())))
+				.endsWith("ORDER BY SOME_COLUMN ASC");
 		assertThat(queryEnhancer.detectAlias()).isNullOrEmpty();
 		assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase("SOME_COLUMN");
 		assertThat(queryEnhancer.hasConstructorExpression()).isFalse();
@@ -132,16 +135,16 @@ void deeplyNestedcomplexSetOperationListWorks() {
 				+ "\tselect CustomerID  from customers where country = 'Germany'\n"//
 				+ "\t;";
 
-		StringQuery stringQuery = new StringQuery(setQuery, true);
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery);
+		DefaultEntityQuery query = new TestEntityQuery(setQuery, true);
+		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query);
 
-		assertThat(stringQuery.getAlias()).isNullOrEmpty();
-		assertThat(stringQuery.getProjection()).isEqualToIgnoringCase("CustomerID");
-		assertThat(stringQuery.hasConstructorExpression()).isFalse();
+		assertThat(query.getAlias()).isNullOrEmpty();
+		assertThat(query.getProjection()).isEqualToIgnoringCase("CustomerID");
+		assertThat(query.hasConstructorExpression()).isFalse();
 
-		assertThat(queryEnhancer.createCountQueryFor()).isEqualToIgnoringCase(setQuery);
-		assertThat(queryEnhancer.applySorting(Sort.by("CustomerID").descending())).endsWith("ORDER BY CustomerID DESC");
-		assertThat(queryEnhancer.getJoinAliases()).isEmpty();
+		assertThat(queryEnhancer.createCountQueryFor(null)).isEqualToIgnoringCase(setQuery);
+		assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("CustomerID").descending())))
+				.endsWith("ORDER BY CustomerID DESC");
 		assertThat(queryEnhancer.detectAlias()).isNullOrEmpty();
 		assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase("CustomerID");
 		assertThat(queryEnhancer.hasConstructorExpression()).isFalse();
@@ -152,16 +155,15 @@ void valuesStatementsWorks() {
 
 		String setQuery = "VALUES (1, 2, 'test')";
 
-		StringQuery stringQuery = new StringQuery(setQuery, true);
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery);
+		DefaultEntityQuery query = new TestEntityQuery(setQuery, true);
+		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query);
 
-		assertThat(stringQuery.getAlias()).isNullOrEmpty();
-		assertThat(stringQuery.getProjection()).isNullOrEmpty();
-		assertThat(stringQuery.hasConstructorExpression()).isFalse();
+		assertThat(query.getAlias()).isNullOrEmpty();
+		assertThat(query.getProjection()).isNullOrEmpty();
+		assertThat(query.hasConstructorExpression()).isFalse();
 
-		assertThat(queryEnhancer.createCountQueryFor()).isEqualToIgnoringCase(setQuery);
-		assertThat(queryEnhancer.applySorting(Sort.by("CustomerID").descending())).isEqualTo(setQuery);
-		assertThat(queryEnhancer.getJoinAliases()).isEmpty();
+		assertThat(queryEnhancer.createCountQueryFor(null)).isEqualToIgnoringCase(setQuery);
+		assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("CustomerID").descending()))).isEqualTo(setQuery);
 		assertThat(queryEnhancer.detectAlias()).isNullOrEmpty();
 		assertThat(queryEnhancer.getProjection()).isNullOrEmpty();
 		assertThat(queryEnhancer.hasConstructorExpression()).isFalse();
@@ -173,18 +175,18 @@ void withStatementsWorks() {
 		String setQuery = "with sample_data(day, value) as (values ((0, 13), (1, 12), (2, 15), (3, 4), (4, 8), (5, 16))) \n"
 				+ "select day, value from sample_data as a";
 
-		StringQuery stringQuery = new StringQuery(setQuery, true);
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery);
+		DefaultEntityQuery query = new TestEntityQuery(setQuery, true);
+		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query);
 
-		assertThat(stringQuery.getAlias()).isEqualToIgnoringCase("a");
-		assertThat(stringQuery.getProjection()).isEqualToIgnoringCase("day, value");
-		assertThat(stringQuery.hasConstructorExpression()).isFalse();
+		assertThat(query.getAlias()).isEqualToIgnoringCase("a");
+		assertThat(query.getProjection()).isEqualToIgnoringCase("day, value");
+		assertThat(query.hasConstructorExpression()).isFalse();
 
-		assertThat(queryEnhancer.createCountQueryFor()).isEqualToIgnoringCase(
+		assertThat(queryEnhancer.createCountQueryFor(null)).isEqualToIgnoringCase(
 				"with sample_data (day, value) AS (VALUES ((0, 13), (1, 12), (2, 15), (3, 4), (4, 8), (5, 16))) "
 						+ "SELECT count(1) FROM sample_data AS a");
-		assertThat(queryEnhancer.applySorting(Sort.by("day").descending())).endsWith("ORDER BY a.day DESC");
-		assertThat(queryEnhancer.getJoinAliases()).isEmpty();
+		assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("day").descending())))
+				.endsWith("ORDER BY a.day DESC");
 		assertThat(queryEnhancer.detectAlias()).isEqualToIgnoringCase("a");
 		assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase("day, value");
 		assertThat(queryEnhancer.hasConstructorExpression()).isFalse();
@@ -196,18 +198,18 @@ void multipleWithStatementsWorks() {
 		String setQuery = "with sample_data(day, value) as (values ((0, 13), (1, 12), (2, 15), (3, 4), (4, 8), (5, 16))), test2 as (values (1,2,3)) \n"
 				+ "select day, value from sample_data as a";
 
-		StringQuery stringQuery = new StringQuery(setQuery, true);
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery);
+		DefaultEntityQuery query = new TestEntityQuery(setQuery, true);
+		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query);
 
-		assertThat(stringQuery.getAlias()).isEqualToIgnoringCase("a");
-		assertThat(stringQuery.getProjection()).isEqualToIgnoringCase("day, value");
-		assertThat(stringQuery.hasConstructorExpression()).isFalse();
+		assertThat(query.getAlias()).isEqualToIgnoringCase("a");
+		assertThat(query.getProjection()).isEqualToIgnoringCase("day, value");
+		assertThat(query.hasConstructorExpression()).isFalse();
 
-		assertThat(queryEnhancer.createCountQueryFor()).isEqualToIgnoringCase(
+		assertThat(queryEnhancer.createCountQueryFor(null)).isEqualToIgnoringCase(
 				"with sample_data (day, value) AS (VALUES ((0, 13), (1, 12), (2, 15), (3, 4), (4, 8), (5, 16))), test2 AS (VALUES (1, 2, 3)) "
 						+ "SELECT count(1) FROM sample_data AS a");
-		assertThat(queryEnhancer.applySorting(Sort.by("day").descending())).endsWith("ORDER BY a.day DESC");
-		assertThat(queryEnhancer.getJoinAliases()).isEmpty();
+		assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("day").descending())))
+				.endsWith("ORDER BY a.day DESC");
 		assertThat(queryEnhancer.detectAlias()).isEqualToIgnoringCase("a");
 		assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase("day, value");
 		assertThat(queryEnhancer.hasConstructorExpression()).isFalse();
@@ -216,31 +218,41 @@ void multipleWithStatementsWorks() {
 	@Test // GH-3038
 	void truncateStatementShouldWork() {
 
-		StringQuery stringQuery = new StringQuery("TRUNCATE TABLE foo", true);
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery);
+		DefaultEntityQuery query = new TestEntityQuery("TRUNCATE TABLE foo", true);
+		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query);
 
-		assertThat(stringQuery.getAlias()).isNull();
-		assertThat(stringQuery.getProjection()).isEmpty();
-		assertThat(stringQuery.hasConstructorExpression()).isFalse();
+		assertThat(query.getAlias()).isNull();
+		assertThat(query.getProjection()).isEmpty();
+		assertThat(query.hasConstructorExpression()).isFalse();
 
-		assertThat(queryEnhancer.applySorting(Sort.by("day").descending())).isEqualTo("TRUNCATE TABLE foo");
-		assertThat(queryEnhancer.getJoinAliases()).isEmpty();
+		assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("day").descending())))
+				.isEqualTo("TRUNCATE TABLE foo");
 		assertThat(queryEnhancer.detectAlias()).isNull();
 		assertThat(queryEnhancer.getProjection()).isEmpty();
 		assertThat(queryEnhancer.hasConstructorExpression()).isFalse();
 	}
 
+	@Test // GH-3869
+	void shouldWorkWithParenthesedSelect() {
+
+		DefaultEntityQuery query = new TestEntityQuery("(SELECT is_contained_in(:innerId, :outerId))", true);
+		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query);
+
+		assertThat(query.getQueryString()).isEqualTo("(SELECT is_contained_in(:innerId, :outerId))");
+		assertThat(query.getAlias()).isNull();
+		assertThat(queryEnhancer.getProjection()).isEqualTo("is_contained_in(:innerId, :outerId)");
+	}
+
 	@ParameterizedTest // GH-2641
 	@MethodSource("mergeStatementWorksSource")
-	void mergeStatementWorksWithJSqlParser(String query, String alias) {
+	void mergeStatementWorksWithJSqlParser(String queryString, String alias) {
 
-		StringQuery stringQuery = new StringQuery(query, true);
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
+		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query);
 
 		assertThat(queryEnhancer.detectAlias()).isEqualTo(alias);
-		assertThat(QueryUtils.detectAlias(query)).isNull();
+		assertThat(QueryUtils.detectAlias(queryString)).isNull();
 
-		assertThat(queryEnhancer.getJoinAliases()).isEmpty();
 		assertThat(queryEnhancer.detectAlias()).isEqualTo(alias);
 		assertThat(queryEnhancer.getProjection()).isEmpty();
 		assertThat(queryEnhancer.hasConstructorExpression()).isFalse();
@@ -257,4 +269,9 @@ static Stream<Arguments> mergeStatementWorksSource() {
 						null));
 	}
 
+	private static DefaultQueryRewriteInformation getRewriteInformation(Sort sort) {
+		return new DefaultQueryRewriteInformation(sort,
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()));
+	}
+
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsTests.java
index aebad09360..937568e01d 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsTests.java
@@ -33,12 +33,12 @@
 import org.assertj.core.api.AbstractAssert;
 import org.assertj.core.api.Assertions;
 import org.assertj.core.api.SoftAssertions;
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.jpa.domain.sample.User;
 import org.springframework.data.jpa.repository.EntityGraph.EntityGraphType;
-import org.springframework.lang.Nullable;
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
 import org.springframework.transaction.annotation.Transactional;
@@ -191,8 +191,7 @@ void allowsEmptyGraph() {
 	/**
 	 * Lookup the {@link AttributeNode} with given {@literal nodeName} in the {@link List} of given {@literal nodes}.
 	 */
-	@Nullable
-	static AttributeNode<?> findNode(String nodeName, List<AttributeNode<?>> nodes) {
+	static @Nullable AttributeNode<?> findNode(String nodeName, List<AttributeNode<?>> nodes) {
 
 		if (CollectionUtils.isEmpty(nodes)) {
 			return null;
@@ -211,8 +210,7 @@ static AttributeNode<?> findNode(String nodeName, List<AttributeNode<?>> nodes)
 	 * Lookup the {@link AttributeNode} with given {@literal nodeName} in the first {@link Subgraph} of the given
 	 * {@literal node}.
 	 */
-	@Nullable
-	static AttributeNode<?> findNode(String attributeName, AttributeNode<?> node) {
+	static @Nullable AttributeNode<?> findNode(String attributeName, AttributeNode<?> node) {
 
 		if (CollectionUtils.isEmpty(node.getSubgraphs())) {
 			return null;
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java
index 9afcf27d56..d44c40301c 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java
@@ -19,21 +19,17 @@
 
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.PersistenceContext;
-import jakarta.persistence.TypedQuery;
 
 import java.lang.reflect.Method;
 import java.util.List;
 
-import org.hibernate.query.spi.SqmQuery;
-import org.hibernate.query.sqm.tree.expression.SqmDistinct;
-import org.hibernate.query.sqm.tree.expression.SqmFunction;
-import org.hibernate.query.sqm.tree.select.SqmSelectClause;
-import org.hibernate.query.sqm.tree.select.SqmSelectStatement;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+
 import org.springframework.data.jpa.domain.sample.Role;
 import org.springframework.data.jpa.domain.sample.User;
 import org.springframework.data.jpa.provider.PersistenceProvider;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
 import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
 import org.springframework.data.repository.Repository;
 import org.springframework.data.repository.core.support.AbstractRepositoryMetadata;
@@ -63,25 +59,15 @@ void distinctFlagOnCountQueryIssuesCountDistinct() throws Exception {
 				AbstractRepositoryMetadata.getMetadata(SomeRepository.class), new SpelAwareProxyProjectionFactory(), provider);
 
 		PartTree tree = new PartTree("findDistinctByRolesIn", User.class);
-		ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(entityManager.getCriteriaBuilder(),
-				queryMethod.getParameters(), EscapeCharacter.DEFAULT);
+		ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(
+				queryMethod.getParameters(), EscapeCharacter.DEFAULT, JpqlQueryTemplates.UPPER);
 
 		JpaCountQueryCreator creator = new JpaCountQueryCreator(tree, queryMethod.getResultProcessor().getReturnedType(),
-				entityManager.getCriteriaBuilder(), metadataProvider);
-
-		TypedQuery<? extends Object> query = entityManager.createQuery(creator.createQuery());
-
-		SqmQuery sqmQuery = ((SqmQuery) query);
-		SqmSelectStatement<?> select = (SqmSelectStatement<?>) sqmQuery.getSqmStatement();
+				metadataProvider, JpqlQueryTemplates.UPPER, entityManager);
 
-		// Verify distinct (should this even be there for a count query?)
-		SqmSelectClause clause = select.getQuerySpec().getSelectClause();
-		assertThat(clause.isDistinct()).isTrue();
+		String query = creator.createQuery();
 
-		// Verify count(distinct(…))
-		SqmFunction<?> function = ((SqmFunction<?>) clause.getSelectionItems().get(0));
-		assertThat(function.getFunctionName()).isEqualTo("count");
-		assertThat(function.getArguments().get(0)).isInstanceOf(SqmDistinct.class);
+		assertThat(query).startsWith("SELECT COUNT(DISTINCT u)");
 	}
 
 	interface SomeRepository extends Repository<User, Integer> {
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreatorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreatorTests.java
new file mode 100644
index 0000000000..dd180bab52
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreatorTests.java
@@ -0,0 +1,95 @@
+/*
+ * 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.springframework.data.jpa.repository.query;
+
+import static org.assertj.core.api.Assertions.*;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.PersistenceContext;
+
+import java.lang.reflect.Method;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.data.domain.KeysetScrollPosition;
+import org.springframework.data.domain.ScrollPosition;
+import org.springframework.data.domain.Window;
+import org.springframework.data.jpa.domain.sample.User;
+import org.springframework.data.jpa.provider.PersistenceProvider;
+import org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformation;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.Repository;
+import org.springframework.data.repository.core.support.AbstractRepositoryMetadata;
+import org.springframework.data.repository.query.parser.PartTree;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+
+/**
+ * Unit tests for {@link JpaKeysetScrollQueryCreator}.
+ *
+ * @author Mark Paluch
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration("classpath:infrastructure.xml")
+class JpaKeysetScrollQueryCreatorTests {
+
+	@PersistenceContext EntityManager entityManager;
+
+	@Test // GH-3588
+	void shouldCreateContinuationQuery() throws Exception {
+
+		Map<String, Object> keys = Map.of("id", "10", "firstname", "John", "emailAddress", "john@example.com");
+		KeysetScrollPosition position = ScrollPosition.of(keys, ScrollPosition.Direction.BACKWARD);
+
+		Method method = MyRepo.class.getMethod("findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc",
+				String.class, ScrollPosition.class);
+
+		PersistenceProvider provider = PersistenceProvider.fromEntityManager(entityManager);
+		JpaQueryMethod queryMethod = new JpaQueryMethod(method, AbstractRepositoryMetadata.getMetadata(MyRepo.class),
+				new SpelAwareProxyProjectionFactory(), provider);
+
+		PartTree tree = new PartTree("findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc", User.class);
+		ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(
+				queryMethod.getParameters(), EscapeCharacter.DEFAULT, JpqlQueryTemplates.UPPER);
+
+		JpaMetamodelEntityInformation<User, User> entityInformation = new JpaMetamodelEntityInformation<>(User.class,
+				entityManager.getMetamodel(), entityManager.getEntityManagerFactory().getPersistenceUnitUtil());
+		JpaKeysetScrollQueryCreator creator = new JpaKeysetScrollQueryCreator(tree,
+				queryMethod.getResultProcessor().getReturnedType(), metadataProvider, JpqlQueryTemplates.UPPER,
+				entityInformation, position, entityManager);
+
+		String query = creator.createQuery();
+
+		assertThat(query).containsIgnoringWhitespaces("""
+				SELECT u FROM org.springframework.data.jpa.domain.sample.User u WHERE (u.firstname LIKE :firstname ESCAPE '\\')
+				AND (u.firstname < :keyset_firstname
+				OR u.firstname = :keyset_firstname AND u.emailAddress < :keyset_emailAddress
+				OR u.firstname = :keyset_firstname AND u.emailAddress = :keyset_emailAddress AND u.id < :keyset_id)
+				ORDER BY u.firstname desc, u.emailAddress desc, u.id desc
+				""");
+	}
+
+	interface MyRepo extends Repository<User, String> {
+
+		Window<User> findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(String firstname,
+				ScrollPosition position);
+
+	}
+
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessorTests.java
index 6d7b55dbf1..0c2727ece4 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessorTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersParameterAccessorTests.java
@@ -69,7 +69,7 @@ void createsHibernateParametersParameterAccessor() throws Exception {
 
 	private void bind(JpaParameters parameters, JpaParametersParameterAccessor accessor) {
 
-		ParameterBinderFactory.createBinder(parameters)
+		ParameterBinderFactory.createBinder(parameters, true)
 				.bind( //
 						QueryParameterSetter.BindableQuery.from(query), //
 						accessor, //
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java
new file mode 100644
index 0000000000..55e9f39122
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java
@@ -0,0 +1,1038 @@
+/*
+ * 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.springframework.data.jpa.repository.query;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import jakarta.persistence.ElementCollection;
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.Id;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+import jakarta.persistence.Tuple;
+import jakarta.persistence.metamodel.Metamodel;
+
+import java.util.Date;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.FieldSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.ScrollPosition;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
+import org.springframework.data.jpa.util.TestMetaModel;
+import org.springframework.data.projection.ProjectionFactory;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.query.ParameterAccessor;
+import org.springframework.data.repository.query.ReturnedType;
+import org.springframework.data.repository.query.parser.PartTree;
+import org.springframework.data.util.Lazy;
+
+/**
+ * Unit tests for {@link JpaQueryCreator}.
+ *
+ * @author Christoph Strobl
+ */
+class JpaQueryCreatorTests {
+
+	private static final TestMetaModel ORDER = TestMetaModel.hibernateModel(Order.class, LineItem.class, Product.class);
+	private static final TestMetaModel PERSON = TestMetaModel.hibernateModel(Person.class);
+
+	static List<JpqlQueryTemplates> ignoreCaseTemplates = List.of(JpqlQueryTemplates.LOWER, JpqlQueryTemplates.UPPER);
+
+	@Test // GH-3588
+	void simpleProperty() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCountry") //
+				.withParameters("AT") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.country = ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void simpleNullProperty() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCountry") //
+				.withParameterTypes(String.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.country IS NULL", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void negatingSimpleProperty() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCountryNot") //
+				.withParameters("US") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.country != ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void negatingSimpleNullProperty() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCountryIsNot") //
+				.withParameterTypes(String.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.country IS NOT NULL", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void simpleAnd() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCountryAndDate") //
+				.withParameters("GB", new Date()) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.country = ?1 AND o.date = ?2", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void simpleOr() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCountryOrDate") //
+				.withParameters("BE", new Date()) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.country = ?1 OR o.date = ?2", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void simpleAndOr() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCountryAndDateOrCompleted") //
+				.withParameters("IT", new Date(), Boolean.FALSE) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.country = ?1 AND o.date = ?2 OR o.completed = ?3",
+						Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void distinct() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findDistinctOrderByCountry") //
+				.withParameters("AU") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT DISTINCT o FROM %s o WHERE o.country = ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void count() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "countOrderByCountry") //
+				.returing(Long.class) //
+				.withParameters("AU") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT COUNT(o) FROM %s o WHERE o.country = ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void countWithJoins() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "countOrderByLineItemsQuantityGreaterThan") //
+				.returing(Long.class) //
+				.withParameterTypes(Integer.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT COUNT(o) FROM %s o LEFT JOIN o.lineItems l WHERE l.quantity > ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void countDistinct() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "countDistinctOrderByCountry") //
+				.returing(Long.class) //
+				.withParameters("AU") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT COUNT(DISTINCT o) FROM %s o WHERE o.country = ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@FieldSource("ignoreCaseTemplates")
+	void simplePropertyIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCountryIgnoreCase") //
+				.ingnoreCaseAs(ingnoreCaseTemplate) //
+				.withParameters("BB") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE %s(o.country) = %s(?1)", Order.class.getName(),
+						ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@FieldSource("ignoreCaseTemplates")
+	void simplePropertyAllIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameAndProductTypeAllIgnoreCase") //
+				.ingnoreCaseAs(ingnoreCaseTemplate) //
+				.withParameters("spring", "data") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE %s(p.name) = %s(?1) AND %s(p.productType) = %s(?2)",
+						Product.class.getName(), ingnoreCaseTemplate.getIgnoreCaseOperator(),
+						ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator(),
+						ingnoreCaseTemplate.getIgnoreCaseOperator()) //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@FieldSource("ignoreCaseTemplates")
+	void simplePropertyMixedCase(JpqlQueryTemplates ingnoreCaseTemplate) {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameAndProductTypeIgnoreCase") //
+				.ingnoreCaseAs(ingnoreCaseTemplate) //
+				.withParameters("spring", "data") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.name = ?1 AND %s(p.productType) = %s(?2)", Product.class.getName(),
+						ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator(),
+						ingnoreCaseTemplate.getIgnoreCaseOperator()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void lessThan() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByDateLessThan") //
+				.withParameterTypes(Date.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.date < ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void lessThanEqual() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByDateLessThanEqual") //
+				.withParameterTypes(Date.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.date <= ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void greaterThan() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByDateGreaterThan") //
+				.withParameterTypes(Date.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.date > ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void before() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByDateBefore") //
+				.withParameterTypes(Date.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.date < ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void after() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByDateAfter") //
+				.withParameterTypes(Date.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.date > ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void between() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByDateBetween") //
+				.withParameterTypes(Date.class, Date.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.date BETWEEN ?1 AND ?2", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void isNull() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByDateIsNull") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.date IS NULL", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void isNotNull() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByDateIsNotNull") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.date IS NOT NULL", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@ValueSource(strings = { "", "spring", "%spring", "spring%", "%spring%" })
+	void like(String parameterValue) {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameLike") //
+				.withParameters(parameterValue) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.name LIKE ?1 ESCAPE '\\'", Product.class.getName()) //
+				.expectPlaceholderValue("?1", parameterValue) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void containingString() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameContaining") //
+				.withParameters("spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.name LIKE ?1 ESCAPE '\\'", Product.class.getName()) //
+				.expectPlaceholderValue("?1", "%spring%") //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void notContainingString() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameNotContaining") //
+				.withParameters("spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.name NOT LIKE ?1 ESCAPE '\\'", Product.class.getName()) //
+				.expectPlaceholderValue("?1", "%spring%") //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void in() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameIn") //
+				.withParameters(List.of("spring", "data")) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.name IN (?1)", Product.class.getName()) //
+				.expectPlaceholderValue("?1", List.of("spring", "data")) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void notIn() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameNotIn") //
+				.withParameters(List.of("spring", "data")) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.name NOT IN (?1)", Product.class.getName()) //
+				.expectPlaceholderValue("?1", List.of("spring", "data")) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void containingSingleEntryElementCollection() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByCategoriesContaining") //
+				.withParameterTypes(String.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE ?1 MEMBER OF p.categories", Product.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void notContainingSingleEntryElementCollection() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByCategoriesNotContaining") //
+				.withParameterTypes(String.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE ?1 NOT MEMBER OF p.categories", Product.class.getName()) //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@FieldSource("ignoreCaseTemplates")
+	void likeWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameLikeIgnoreCase") //
+				.ingnoreCaseAs(ingnoreCaseTemplate) //
+				.withParameters("%spring%") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE %s(p.name) LIKE %s(?1) ESCAPE '\\'", Product.class.getName(),
+						ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) //
+				.expectPlaceholderValue("?1", "%spring%") //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@ValueSource(strings = { "", "spring", "%spring", "spring%", "%spring%" })
+	void notLike(String parameterValue) {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameNotLike") //
+				.withParameters(parameterValue) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.name NOT LIKE ?1 ESCAPE '\\'", Product.class.getName()) //
+				.expectPlaceholderValue("?1", parameterValue) //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@FieldSource("ignoreCaseTemplates")
+	void notLikeWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameNotLikeIgnoreCase") //
+				.ingnoreCaseAs(ingnoreCaseTemplate) //
+				.withParameters("%spring%") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE %s(p.name) NOT LIKE %s(?1) ESCAPE '\\'", Product.class.getName(),
+						ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) //
+				.expectPlaceholderValue("?1", "%spring%") //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void startingWith() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameStartingWith") //
+				.withParameters("spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.name LIKE ?1 ESCAPE '\\'", Product.class.getName()) //
+				.expectPlaceholderValue("?1", "spring%") //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@FieldSource("ignoreCaseTemplates")
+	void startingWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameStartingWithIgnoreCase") //
+				.ingnoreCaseAs(ingnoreCaseTemplate) //
+				.withParameters("spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE %s(p.name) LIKE %s(?1) ESCAPE '\\'", Product.class.getName(),
+						ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) //
+				.expectPlaceholderValue("?1", "spring%") //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void endingWith() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameEndingWith") //
+				.withParameters("spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.name LIKE ?1 ESCAPE '\\'", Product.class.getName()) //
+				.expectPlaceholderValue("?1", "%spring") //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@FieldSource("ignoreCaseTemplates")
+	void endingWithIgnoreCase(JpqlQueryTemplates ingnoreCaseTemplate) {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProductByNameEndingWithIgnoreCase") //
+				.ingnoreCaseAs(ingnoreCaseTemplate) //
+				.withParameters("spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE %s(p.name) LIKE %s(?1) ESCAPE '\\'", Product.class.getName(),
+						ingnoreCaseTemplate.getIgnoreCaseOperator(), ingnoreCaseTemplate.getIgnoreCaseOperator()) //
+				.expectPlaceholderValue("?1", "%spring") //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void greaterThanEqual() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByDateGreaterThanEqual") //
+				.withParameterTypes(Date.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.date >= ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void isTrue() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCompletedIsTrue") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.completed = TRUE", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void isFalse() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCompletedIsFalse") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.completed = FALSE", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void empty() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByLineItemsEmpty") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.lineItems IS EMPTY", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void notEmpty() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByLineItemsNotEmpty") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.lineItems IS NOT EMPTY", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void sortBySingle() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByCountryOrderByDate") //
+				.withParameters("CA") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o WHERE o.country = ?1 ORDER BY o.date asc", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void sortByMulti() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByOrderByCountryAscDateDesc") //
+				.withParameters() //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o ORDER BY o.country asc, o.date desc", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Disabled("should we support this?")
+	@ParameterizedTest // GH-3588
+	@FieldSource("ignoreCaseTemplates")
+	void sortBySingleIngoreCase(JpqlQueryTemplates ingoreCase) {
+
+		String jpql = queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByOrderByCountryAscAllIgnoreCase") //
+				.render();
+
+		assertThat(jpql).isEqualTo("SELECT o FROM %s o ORDER BY %s(o.date) asc", Order.class.getName(),
+				ingoreCase.getIgnoreCaseOperator());
+	}
+
+	@Test // GH-3588
+	void matchSimpleJoin() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByLineItemsQuantityGreaterThan") //
+				.withParameterTypes(Integer.class) //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o LEFT JOIN o.lineItems l WHERE l.quantity > ?1", Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void matchSimpleNestedJoin() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByLineItemsProductNameIs") //
+				.withParameters("spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT o FROM %s o LEFT JOIN o.lineItems l LEFT JOIN l.product p WHERE p.name = ?1",
+						Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void matchMultiOnNestedJoin() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByLineItemsQuantityGreaterThanAndLineItemsProductNameIs") //
+				.withParameters(10, "spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql(
+						"SELECT o FROM %s o LEFT JOIN o.lineItems l LEFT JOIN l.product p WHERE l.quantity > ?1 AND p.name = ?2",
+						Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void matchSameEntityMultipleTimes() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByLineItemsProductNameIsAndLineItemsProductNameIsNot") //
+				.withParameters("spring", "sukrauq") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql(
+						"SELECT o FROM %s o LEFT JOIN o.lineItems l LEFT JOIN l.product p WHERE p.name = ?1 AND p.name != ?2",
+						Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void matchSameEntityMultipleTimesViaDifferentProperties() {
+
+		queryCreator(ORDER) //
+				.forTree(Order.class, "findOrderByLineItemsProductNameIsAndLineItemsProduct2NameIs") //
+				.withParameters(10, "spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql(
+						"SELECT o FROM %s o LEFT JOIN o.lineItems l LEFT JOIN l.product p LEFT JOIN l.product2 join_0 WHERE p.name = ?1 AND join_0.name = ?2",
+						Order.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void dtoProjection() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProjectionByNameIs") //
+				.returing(DtoProductProjection.class) //
+				.withParameters("spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT new %s(p.name, p.productType) FROM %s p WHERE p.name = ?1",
+						DtoProductProjection.class.getName(), Product.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void interfaceProjection() {
+
+		queryCreator(ORDER) //
+				.forTree(Product.class, "findProjectionByNameIs") //
+				.returing(InterfaceProductProjection.class) //
+				.withParameters("spring") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p.name name, p.productType productType FROM %s p WHERE p.name = ?1",
+						Product.class.getName()) //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@ValueSource(classes = { Tuple.class, Map.class })
+	void tupleProjection(Class<?> resultType) {
+
+		queryCreator(PERSON) //
+				.forTree(Person.class, "findProjectionByFirstnameIs") //
+				.returing(resultType) //
+				.withParameters("chris") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p.id id, p.firstname firstname, p.lastname lastname FROM %s p WHERE p.firstname = ?1",
+						Person.class.getName()) //
+				.validateQuery();
+	}
+
+	@ParameterizedTest // GH-3588
+	@ValueSource(classes = { Long.class, List.class, Person.class })
+	void delete(Class<?> resultType) {
+
+		queryCreator(PERSON) //
+				.forTree(Person.class, "deletePersonByFirstname") //
+				.returing(resultType) //
+				.withParameters("chris") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p FROM %s p WHERE p.firstname = ?1", Person.class.getName()) //
+				.validateQuery();
+	}
+
+	@Test // GH-3588
+	void exists() {
+
+		queryCreator(PERSON) //
+				.forTree(Person.class, "existsPersonByFirstname") //
+				.returing(Long.class).withParameters("chris") //
+				.as(QueryCreatorTester::create) //
+				.expectJpql("SELECT p.id id FROM %s p WHERE p.firstname = ?1", Person.class.getName()) //
+				.validateQuery();
+	}
+
+	QueryCreatorBuilder queryCreator(Metamodel metamodel) {
+		return new DefaultCreatorBuilder(metamodel);
+	}
+
+	JpaQueryCreator queryCreator(PartTree tree, ReturnedType returnedType, Metamodel metamodel, Object... arguments) {
+		return queryCreator(tree, returnedType, metamodel, JpqlQueryTemplates.UPPER, arguments);
+	}
+
+	JpaQueryCreator queryCreator(PartTree tree, ReturnedType returnedType, Metamodel metamodel,
+			JpqlQueryTemplates templates, Object... arguments) {
+
+		ParameterMetadataProvider parameterMetadataProvider = new ParameterMetadataProvider(
+				StubJpaParameterParameterAccessor.accessor(arguments), EscapeCharacter.DEFAULT, templates);
+		return queryCreator(tree, returnedType, metamodel, templates, parameterMetadataProvider);
+	}
+
+	JpaQueryCreator queryCreator(PartTree tree, ReturnedType returnedType, Metamodel metamodel,
+			JpqlQueryTemplates templates, Class<?>... argumentTypes) {
+
+		ParameterMetadataProvider parameterMetadataProvider = new ParameterMetadataProvider(
+				StubJpaParameterParameterAccessor.accessor(argumentTypes), EscapeCharacter.DEFAULT, templates);
+		return queryCreator(tree, returnedType, metamodel, templates, parameterMetadataProvider);
+	}
+
+	JpaQueryCreator queryCreator(PartTree tree, ReturnedType returnedType, Metamodel metamodel,
+			JpqlQueryTemplates templates, JpaParametersParameterAccessor parameterAccessor) {
+
+		EntityManager entityManager = mock(EntityManager.class);
+		when(entityManager.getMetamodel()).thenReturn(metamodel);
+
+		ParameterMetadataProvider parameterMetadataProvider = new ParameterMetadataProvider(parameterAccessor,
+				EscapeCharacter.DEFAULT, templates);
+		return new JpaQueryCreator(tree, returnedType, parameterMetadataProvider, templates, entityManager);
+	}
+
+	@SuppressWarnings({ "rawtypes", "unchecked" })
+	private JpaParametersParameterAccessor accessor(Class<?>... argumentTypes) {
+
+		return StubJpaParameterParameterAccessor.accessor(argumentTypes);
+	}
+
+	@jakarta.persistence.Entity
+	static class Order {
+
+		@Id Long id;
+		Date date;
+		String country;
+		Boolean completed;
+
+		@OneToMany List<LineItem> lineItems;
+	}
+
+	@jakarta.persistence.Entity
+	static class LineItem {
+
+		@Id Long id;
+
+		@ManyToOne Product product;
+		@ManyToOne Product product2;
+		int quantity;
+
+	}
+
+	@jakarta.persistence.Entity
+	static class Person {
+		@Id Long id;
+		String firstname;
+		String lastname;
+	}
+
+	@jakarta.persistence.Entity
+	static class Product {
+
+		@Id Long id;
+
+		String name;
+		String productType;
+
+		@ElementCollection List<String> categories;
+	}
+
+	static class DtoProductProjection {
+
+		String name;
+		String productType;
+
+		DtoProductProjection(String name, String productType) {
+			this.name = name;
+			this.productType = productType;
+		}
+	}
+
+	interface InterfaceProductProjection {
+		String getName();
+
+		String getProductType();
+	}
+
+	static class QueryCreatorTester {
+
+		QueryCreatorBuilder builder;
+		Lazy<String> jpql;
+
+		private QueryCreatorTester(QueryCreatorBuilder builder) {
+			this.builder = builder;
+			this.jpql = Lazy.of(builder::render);
+		}
+
+		static QueryCreatorTester create(QueryCreatorBuilder builder) {
+			return new QueryCreatorTester(builder);
+		}
+
+		QueryCreatorTester expectJpql(String jpql, Object... args) {
+
+			assertThat(this.jpql.get()).isEqualTo(jpql, args);
+			return this;
+		}
+
+		QueryCreatorTester expectPlaceholderValue(String placeholder, Object value) {
+			return expectBindingAt(builder.bindingIndexFor(placeholder), value);
+		}
+
+		QueryCreatorTester expectBindingAt(int position, Object value) {
+
+			Object current = builder.bindableParameters().getBindableValue(position - 1);
+			assertThat(current).isEqualTo(value);
+			return this;
+		}
+
+		QueryCreatorTester validateQuery() {
+
+			if (builder instanceof DefaultCreatorBuilder dcb && dcb.metamodel instanceof TestMetaModel tmm) {
+				return validateQuery(tmm.entityManager());
+			}
+
+			throw new IllegalStateException("No EntityManager found, plase provide one via [verify(EntityManager)]");
+		}
+
+		QueryCreatorTester validateQuery(EntityManager entityManager) {
+
+			if (builder instanceof DefaultCreatorBuilder dcb) {
+				entityManager.createQuery(this.jpql.get(), dcb.returnedType.getReturnedType());
+			} else {
+				entityManager.createQuery(this.jpql.get());
+			}
+			return this;
+		}
+
+	}
+
+	interface QueryCreatorBuilder {
+
+		QueryCreatorBuilder returing(ReturnedType returnedType);
+
+		QueryCreatorBuilder forTree(Class<?> root, String querySource);
+
+		QueryCreatorBuilder withParameters(Object... arguments);
+
+		QueryCreatorBuilder withParameterTypes(Class<?>... argumentTypes);
+
+		QueryCreatorBuilder ingnoreCaseAs(JpqlQueryTemplates queryTemplate);
+
+		default <T> T as(Function<QueryCreatorBuilder, T> transformer) {
+			return transformer.apply(this);
+		}
+
+		default String render() {
+			return render(null);
+		}
+
+		ParameterAccessor bindableParameters();
+
+		int bindingIndexFor(String placeholder);
+
+		String render(@Nullable Sort sort);
+
+		QueryCreatorBuilder returing(Class<?> type);
+	}
+
+	private class DefaultCreatorBuilder implements QueryCreatorBuilder {
+
+		private static final ProjectionFactory PROJECTION_FACTORY = new SpelAwareProxyProjectionFactory();
+
+		private final Metamodel metamodel;
+		private ReturnedType returnedType;
+		private PartTree partTree;
+		private Object[] arguments;
+		private Class<?>[] argumentTypes;
+		private JpqlQueryTemplates queryTemplates;
+		private Lazy<JpaQueryCreator> queryCreator = Lazy.of(this::initJpaQueryCreator);
+		private Lazy<JpaParametersParameterAccessor> parameterAccessor = Lazy.of(this::initParameterAccessor);
+
+		public DefaultCreatorBuilder(Metamodel metamodel) {
+			this.metamodel = metamodel;
+			arguments = new Object[0];
+			queryTemplates = JpqlQueryTemplates.UPPER;
+		}
+
+		@Override
+		public QueryCreatorBuilder returing(ReturnedType returnedType) {
+			this.returnedType = returnedType;
+			return this;
+		}
+
+		@Override
+		public QueryCreatorBuilder returing(Class<?> type) {
+
+			if (this.returnedType != null) {
+				return returing(ReturnedType.of(type, returnedType.getDomainType(), PROJECTION_FACTORY));
+			}
+
+			return returing(ReturnedType.of(type, type, PROJECTION_FACTORY));
+		}
+
+		@Override
+		public QueryCreatorBuilder forTree(Class<?> root, String querySource) {
+
+			this.partTree = new PartTree(querySource, root);
+			if (returnedType == null) {
+				returnedType = ReturnedType.of(root, root, PROJECTION_FACTORY);
+			}
+			return this;
+		}
+
+		@Override
+		public QueryCreatorBuilder withParameters(Object... arguments) {
+			this.arguments = arguments;
+			return this;
+		}
+
+		@Override
+		public QueryCreatorBuilder withParameterTypes(Class<?>... argumentTypes) {
+			this.argumentTypes = argumentTypes;
+			return this;
+		}
+
+		@Override
+		public QueryCreatorBuilder ingnoreCaseAs(JpqlQueryTemplates queryTemplate) {
+			this.queryTemplates = queryTemplate;
+			return this;
+		}
+
+		@Override
+		public String render(@Nullable Sort sort) {
+			return queryCreator.get().createQuery(sort != null ? sort : Sort.unsorted());
+		}
+
+		@Override
+		public int bindingIndexFor(String placeholder) {
+
+			return queryCreator.get().getBindings().stream().filter(binding -> {
+
+				if (binding.getIdentifier().hasPosition() && placeholder.startsWith("?")) {
+					return binding.getPosition() == Integer.parseInt(placeholder.substring(1));
+				}
+
+				if (!binding.getIdentifier().hasName()) {
+					return false;
+				}
+
+				return binding.getIdentifier().getName().equals(placeholder);
+			}).findFirst().map(ParameterBinding::getPosition).orElse(-1);
+		}
+
+		@Override
+		public ParameterAccessor bindableParameters() {
+
+			return new ParameterAccessor() {
+				@Override
+				public @Nullable ScrollPosition getScrollPosition() {
+					return null;
+				}
+
+				@Override
+				public Pageable getPageable() {
+					return null;
+				}
+
+				@Override
+				public Sort getSort() {
+					return null;
+				}
+
+				@Override
+				public @Nullable Class<?> findDynamicProjection() {
+					return null;
+				}
+
+				@Override
+				public @Nullable Object getBindableValue(int index) {
+
+					ParameterBinding parameterBinding = queryCreator.get().getBindings().get(index);
+					return parameterBinding.prepare(parameterAccessor.get().getBindableValue(index));
+				}
+
+				@Override
+				public boolean hasBindableNullValue() {
+					return false;
+				}
+
+				@Override
+				public Iterator<Object> iterator() {
+					return null;
+				}
+			};
+
+		}
+
+		JpaParametersParameterAccessor initParameterAccessor() {
+
+			if (arguments.length > 0 || argumentTypes == null) {
+				return StubJpaParameterParameterAccessor.accessor(arguments);
+			}
+			return StubJpaParameterParameterAccessor.accessor(argumentTypes);
+		}
+
+		JpaQueryCreator initJpaQueryCreator() {
+
+			if (arguments.length > 0 || argumentTypes == null) {
+				return queryCreator(partTree, returnedType, metamodel, queryTemplates, parameterAccessor.get());
+			}
+			return queryCreator(partTree, returnedType, metamodel, queryTemplates, parameterAccessor.get());
+		}
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryExecutionUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryExecutionUnitTests.java
index 6d93f6ae9f..c7d160d6d1 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryExecutionUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryExecutionUnitTests.java
@@ -24,6 +24,7 @@
 import jakarta.persistence.TypedQuery;
 
 import java.lang.reflect.Method;
+import java.math.BigDecimal;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Optional;
@@ -39,6 +40,7 @@
 
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.provider.PersistenceProvider;
 import org.springframework.data.jpa.provider.QueryExtractor;
 import org.springframework.data.jpa.repository.Modifying;
 import org.springframework.data.jpa.repository.query.JpaQueryExecution.ModifyingExecution;
@@ -169,7 +171,7 @@ void allowsMethodReturnTypesForModifyingQuery() {
 	@Test
 	void modifyingExecutionRejectsNonIntegerOrVoidReturnType() {
 
-		when(method.getReturnType()).thenReturn((Class) Long.class);
+		when(method.getReturnType()).thenReturn((Class) BigDecimal.class);
 		assertThatIllegalArgumentException().isThrownBy(() -> new ModifyingExecution(method, em));
 	}
 
@@ -182,7 +184,7 @@ void pagedExecutionRetrievesObjectsForPageableOutOfRange() throws Exception {
 		when(jpaQuery.createQuery(Mockito.any())).thenReturn(query);
 		when(countQuery.getResultList()).thenReturn(Arrays.asList(20L));
 
-		PagedExecution execution = new PagedExecution();
+		PagedExecution execution = new PagedExecution(PersistenceProvider.GENERIC_JPA);
 		execution.doExecute(jpaQuery,
 				new JpaParametersParameterAccessor(parameters, new Object[] { PageRequest.of(2, 10) }));
 
@@ -198,7 +200,7 @@ void pagedExecutionShouldNotGenerateCountQueryIfQueryReportedNoResults() throws
 		when(jpaQuery.createQuery(Mockito.any())).thenReturn(query);
 		when(query.getResultList()).thenReturn(Arrays.asList(0L));
 
-		PagedExecution execution = new PagedExecution();
+		PagedExecution execution = new PagedExecution(PersistenceProvider.GENERIC_JPA);
 		execution.doExecute(jpaQuery,
 				new JpaParametersParameterAccessor(parameters, new Object[] { PageRequest.of(0, 10) }));
 
@@ -214,7 +216,7 @@ void pagedExecutionShouldUseCountFromResultIfOffsetIsZeroAndResultsWithinPageSiz
 		when(jpaQuery.createQuery(Mockito.any())).thenReturn(query);
 		when(query.getResultList()).thenReturn(Arrays.asList(new Object(), new Object(), new Object(), new Object()));
 
-		PagedExecution execution = new PagedExecution();
+		PagedExecution execution = new PagedExecution(PersistenceProvider.GENERIC_JPA);
 		execution.doExecute(jpaQuery,
 				new JpaParametersParameterAccessor(parameters, new Object[] { PageRequest.of(0, 10) }));
 
@@ -229,7 +231,7 @@ void pagedExecutionShouldUseCountFromResultWithOffsetAndResultsWithinPageSize()
 		when(jpaQuery.createQuery(Mockito.any())).thenReturn(query);
 		when(query.getResultList()).thenReturn(Arrays.asList(new Object(), new Object(), new Object(), new Object()));
 
-		PagedExecution execution = new PagedExecution();
+		PagedExecution execution = new PagedExecution(PersistenceProvider.GENERIC_JPA);
 		execution.doExecute(jpaQuery,
 				new JpaParametersParameterAccessor(parameters, new Object[] { PageRequest.of(5, 10) }));
 
@@ -246,7 +248,7 @@ void pagedExecutionShouldUseRequestCountFromResultWithOffsetAndResultsHitLowerPa
 		when(jpaQuery.createCountQuery(Mockito.any())).thenReturn(query);
 		when(countQuery.getResultList()).thenReturn(Arrays.asList(20L));
 
-		PagedExecution execution = new PagedExecution();
+		PagedExecution execution = new PagedExecution(PersistenceProvider.GENERIC_JPA);
 		execution.doExecute(jpaQuery,
 				new JpaParametersParameterAccessor(parameters, new Object[] { PageRequest.of(4, 4) }));
 
@@ -263,7 +265,7 @@ void pagedExecutionShouldUseRequestCountFromResultWithOffsetAndResultsHitUpperPa
 		when(jpaQuery.createCountQuery(Mockito.any())).thenReturn(query);
 		when(countQuery.getResultList()).thenReturn(Arrays.asList(20L));
 
-		PagedExecution execution = new PagedExecution();
+		PagedExecution execution = new PagedExecution(PersistenceProvider.GENERIC_JPA);
 		execution.doExecute(jpaQuery,
 				new JpaParametersParameterAccessor(parameters, new Object[] { PageRequest.of(4, 4) }));
 
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategyUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategyUnitTests.java
index a8205cea35..e68faf4092 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategyUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategyUnitTests.java
@@ -33,7 +33,7 @@
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
-import org.springframework.beans.factory.BeanFactory;
+
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Pageable;
 import org.springframework.data.domain.Sort;
@@ -47,8 +47,8 @@
 import org.springframework.data.repository.core.support.DefaultRepositoryMetadata;
 import org.springframework.data.repository.query.QueryLookupStrategy;
 import org.springframework.data.repository.query.QueryLookupStrategy.Key;
-import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider;
 import org.springframework.data.repository.query.RepositoryQuery;
+import org.springframework.data.repository.query.ValueExpressionDelegate;
 
 /**
  * Unit tests for {@link JpaQueryLookupStrategy}.
@@ -63,7 +63,8 @@
 @MockitoSettings(strictness = Strictness.LENIENT)
 class JpaQueryLookupStrategyUnitTests {
 
-	private static final QueryMethodEvaluationContextProvider EVALUATION_CONTEXT_PROVIDER = QueryMethodEvaluationContextProvider.DEFAULT;
+	private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(),
+			QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT);
 
 	@Mock EntityManager em;
 	@Mock EntityManagerFactory emf;
@@ -71,7 +72,6 @@ class JpaQueryLookupStrategyUnitTests {
 	@Mock NamedQueries namedQueries;
 	@Mock Metamodel metamodel;
 	@Mock ProjectionFactory projectionFactory;
-	@Mock BeanFactory beanFactory;
 
 	private JpaQueryMethodFactory queryMethodFactory;
 
@@ -89,7 +89,7 @@ void setUp() {
 	void invalidAnnotatedQueryCausesException() throws Exception {
 
 		QueryLookupStrategy strategy = JpaQueryLookupStrategy.create(em, queryMethodFactory, Key.CREATE_IF_NOT_FOUND,
-				EVALUATION_CONTEXT_PROVIDER, new BeanFactoryQueryRewriterProvider(beanFactory), EscapeCharacter.DEFAULT);
+				CONFIG);
 		Method method = UserRepository.class.getMethod("findByFoo", String.class);
 		RepositoryMetadata metadata = new DefaultRepositoryMetadata(UserRepository.class);
 
@@ -101,7 +101,7 @@ void invalidAnnotatedQueryCausesException() throws Exception {
 	void considersNamedCountQuery() throws Exception {
 
 		QueryLookupStrategy strategy = JpaQueryLookupStrategy.create(em, queryMethodFactory, Key.CREATE_IF_NOT_FOUND,
-				EVALUATION_CONTEXT_PROVIDER, new BeanFactoryQueryRewriterProvider(beanFactory), EscapeCharacter.DEFAULT);
+				CONFIG);
 
 		when(namedQueries.hasQuery("foo.count")).thenReturn(true);
 		when(namedQueries.getQuery("foo.count")).thenReturn("select count(foo) from Foo foo");
@@ -123,7 +123,7 @@ void considersNamedCountQuery() throws Exception {
 	void considersNamedCountOnStringQueryQuery() throws Exception {
 
 		QueryLookupStrategy strategy = JpaQueryLookupStrategy.create(em, queryMethodFactory, Key.CREATE_IF_NOT_FOUND,
-				EVALUATION_CONTEXT_PROVIDER, new BeanFactoryQueryRewriterProvider(beanFactory), EscapeCharacter.DEFAULT);
+				CONFIG);
 
 		when(namedQueries.hasQuery("foo.count")).thenReturn(true);
 		when(namedQueries.getQuery("foo.count")).thenReturn("select count(foo) from Foo foo");
@@ -142,7 +142,7 @@ void considersNamedCountOnStringQueryQuery() throws Exception {
 	void prefersDeclaredQuery() throws Exception {
 
 		QueryLookupStrategy strategy = JpaQueryLookupStrategy.create(em, queryMethodFactory, Key.CREATE_IF_NOT_FOUND,
-				EVALUATION_CONTEXT_PROVIDER, new BeanFactoryQueryRewriterProvider(beanFactory), EscapeCharacter.DEFAULT);
+				CONFIG);
 		Method method = UserRepository.class.getMethod("annotatedQueryWithQueryAndQueryName");
 		RepositoryMetadata metadata = new DefaultRepositoryMetadata(UserRepository.class);
 
@@ -155,7 +155,7 @@ void prefersDeclaredQuery() throws Exception {
 	void namedQueryWithSortShouldThrowIllegalStateException() throws NoSuchMethodException {
 
 		QueryLookupStrategy strategy = JpaQueryLookupStrategy.create(em, queryMethodFactory, Key.CREATE_IF_NOT_FOUND,
-				EVALUATION_CONTEXT_PROVIDER, new BeanFactoryQueryRewriterProvider(beanFactory), EscapeCharacter.DEFAULT);
+				CONFIG);
 
 		Method method = UserRepository.class.getMethod("customNamedQuery", String.class, Sort.class);
 		RepositoryMetadata metadata = new DefaultRepositoryMetadata(UserRepository.class);
@@ -180,7 +180,7 @@ void noQueryShouldNotBeInvoked() {
 	void customQueryWithQuestionMarksShouldWork() throws NoSuchMethodException {
 
 		QueryLookupStrategy strategy = JpaQueryLookupStrategy.create(em, queryMethodFactory, Key.CREATE_IF_NOT_FOUND,
-				EVALUATION_CONTEXT_PROVIDER, new BeanFactoryQueryRewriterProvider(beanFactory), EscapeCharacter.DEFAULT);
+				CONFIG);
 
 		Method namedMethod = UserRepository.class.getMethod("customQueryWithQuestionMarksAndNamedParam", String.class);
 		RepositoryMetadata namedMetadata = new DefaultRepositoryMetadata(UserRepository.class);
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryRewriteIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryRewriteIntegrationTests.java
index 9637785e39..2d44dbf0a5 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryRewriteIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryRewriteIntegrationTests.java
@@ -15,8 +15,7 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.entry;
+import static org.assertj.core.api.Assertions.*;
 
 import java.util.HashMap;
 import java.util.LinkedHashSet;
@@ -27,6 +26,7 @@
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
+
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.ComponentScan;
@@ -43,8 +43,11 @@
 import org.springframework.data.jpa.repository.Query;
 import org.springframework.data.jpa.repository.QueryRewriter;
 import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.data.jpa.repository.support.JpaRepositoryFactory;
+import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean;
 import org.springframework.test.context.ContextConfiguration;
 import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.util.ReflectionTestUtils;
 
 /**
  * Unit tests for repository with {@link Query} and {@link QueryRewriter}.
@@ -57,6 +60,7 @@
 class JpaQueryRewriteIntegrationTests {
 
 	@Autowired private UserRepositoryWithRewriter repository;
+	@Autowired private JpaRepositoryFactoryBean<UserRepositoryWithRewriter, User, Integer> factoryBean;
 
 	// Results
 	static final String ORIGINAL_QUERY = "original query";
@@ -71,6 +75,14 @@ void setUp() {
 		repository.deleteAll();
 	}
 
+	@Test
+	void shouldConfigureQueryEnhancerSelector() {
+
+		JpaRepositoryFactory factory = (JpaRepositoryFactory) ReflectionTestUtils.getField(factoryBean, "factory");
+
+		assertThat(factory).extracting("queryEnhancerSelector").isInstanceOf(MyQueryEnhancerSelector.class);
+	}
+
 	@Test
 	void nativeQueryShouldHandleRewrites() {
 
@@ -228,7 +240,8 @@ private static String replaceAlias(String query, Sort sort) {
 	@ImportResource("classpath:infrastructure.xml")
 	@EnableJpaRepositories(considerNestedRepositories = true, basePackageClasses = UserRepositoryWithRewriter.class, //
 			includeFilters = @ComponentScan.Filter(value = { UserRepositoryWithRewriter.class },
-					type = FilterType.ASSIGNABLE_TYPE))
+					type = FilterType.ASSIGNABLE_TYPE),
+			queryEnhancerSelector = MyQueryEnhancerSelector.class)
 	static class JpaRepositoryConfig {
 
 		@Bean
@@ -237,4 +250,10 @@ QueryRewriter queryRewriter() {
 		}
 
 	}
+
+	static class MyQueryEnhancerSelector extends QueryEnhancerSelector.DefaultQueryEnhancerSelector {
+		public MyQueryEnhancerSelector() {
+			super(QueryEnhancerFactories.fallback(), DefaultQueryEnhancerSelector.jpql());
+		}
+	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlComplianceTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlComplianceTests.java
deleted file mode 100644
index 81722f9b90..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlComplianceTests.java
+++ /dev/null
@@ -1,66 +0,0 @@
-/*
- * 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.springframework.data.jpa.repository.query;
-
-import static org.assertj.core.api.Assertions.*;
-
-import org.junit.jupiter.api.Test;
-
-/**
- * Test to verify compliance of {@link JpqlParser} with standard SQL. Other than {@link JpqlSpecificationTests} tests in
- * this class check that the parser follows a lenient approach and does not error on well known concepts like numeric
- * suffix.
- *
- * @author Christoph Strobl
- */
-class JpqlComplianceTests {
-
-	private static String parseWithoutChanges(String query) {
-
-		JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser.parseQuery(query);
-
-		return QueryRenderer.render(new JpqlQueryRenderer().visit(parser.getContext()));
-	}
-
-	private void assertQuery(String query) {
-
-		String slimmedDownQuery = reduceWhitespace(query);
-		assertThat(parseWithoutChanges(slimmedDownQuery)).isEqualTo(slimmedDownQuery);
-	}
-
-	private String reduceWhitespace(String original) {
-
-		return original //
-				.replaceAll("[ \\t\\n]{1,}", " ") //
-				.trim();
-	}
-
-	@Test // GH-3277
-	void numericLiterals() {
-
-		assertQuery("SELECT e FROM Employee e WHERE e.id = 1234");
-		assertQuery("SELECT e FROM Employee e WHERE e.id = 1234L");
-		assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14");
-		assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14F");
-		assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14e32D");
-	}
-
-	@Test // GH-3308
-	void newWithStrings() {
-		assertQuery("select new com.example.demo.SampleObject(se.id, se.sampleValue, \"java\") from SampleEntity se");
-	}
-
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java
index 38c416271f..d0c8fa1305 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java
@@ -63,11 +63,11 @@ void shouldRewriteQueriesWithSubselect() {
 	void shouldNotTranslateConstructorExpressionQuery() {
 
 		JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser
-				.parseQuery("SELECT NEW String(p) from Person p");
+				.parseQuery("SELECT NEW Foo(p) from Person p");
 
 		QueryTokenStream visit = getTransformer(parser).visit(parser.getContext());
 
-		assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW String(p) from Person p");
+		assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW Foo(p) from Person p");
 	}
 
 	@Test
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlParserQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlParserQueryEnhancerUnitTests.java
index 8b6385e65d..44256fe4c9 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlParserQueryEnhancerUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlParserQueryEnhancerUnitTests.java
@@ -30,9 +30,9 @@ public class JpqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests {
 	@Override
 	QueryEnhancer createQueryEnhancer(DeclaredQuery query) {
 
-		assumeThat(query.isNativeQuery()).isFalse();
+		assumeThat(query.isNative()).isFalse();
 
-		return JpaQueryEnhancer.forJpql(query);
+		return JpaQueryEnhancer.forJpql(query.getQueryString());
 	}
 
 	@Override
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java
new file mode 100644
index 0000000000..d2ac172373
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java
@@ -0,0 +1,221 @@
+/*
+ * 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.springframework.data.jpa.repository.query;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.springframework.data.jpa.repository.query.JpqlQueryBuilder.*;
+
+import jakarta.persistence.Id;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.OneToMany;
+
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link JpqlQueryBuilder}.
+ *
+ * @author Christoph Strobl
+ */
+class JpqlQueryBuilderUnitTests {
+
+	@Test // GH-3588
+	void placeholdersRenderCorrectly() {
+
+		assertThat(JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(1)).render(RenderContext.EMPTY)).isEqualTo("?1");
+		assertThat(JpqlQueryBuilder.parameter(ParameterPlaceholder.named("arg1")).render(RenderContext.EMPTY))
+				.isEqualTo(":arg1");
+		assertThat(JpqlQueryBuilder.parameter("?1").render(RenderContext.EMPTY)).isEqualTo("?1");
+	}
+
+	@Test // GH-3588
+	void placeholdersErrorOnInvalidInput() {
+		assertThatExceptionOfType(IllegalArgumentException.class)
+				.isThrownBy(() -> JpqlQueryBuilder.parameter((String) null));
+		assertThatExceptionOfType(IllegalArgumentException.class).isThrownBy(() -> JpqlQueryBuilder.parameter(""));
+	}
+
+	@Test // GH-3588
+	void stringLiteralRendersAsQuotedString() {
+
+		assertThat(literal("literal").render(RenderContext.EMPTY)).isEqualTo("'literal'");
+
+		/* JPA Spec - 4.6.1 Literals:
+		   > A string literal that includes a single quote is represented by two single quotes--for example: 'literal''s'. */
+		assertThat(literal("literal's").render(RenderContext.EMPTY)).isEqualTo("'literal''s'");
+	}
+
+	@Test // GH-3588
+	void entity() {
+
+		Entity entity = JpqlQueryBuilder.entity(Order.class);
+		assertThat(entity.getAlias()).isEqualTo("o");
+		assertThat(entity.getEntity()).isEqualTo(Order.class.getName());
+		assertThat(entity.getName()).isEqualTo(Order.class.getSimpleName());
+	}
+
+	@Test // GH-3588
+	void literalExpressionRendersAsIs() {
+		Expression expression = expression("CONCAT(person.lastName, ‘, ’, person.firstName))");
+		assertThat(expression.render(RenderContext.EMPTY)).isEqualTo("CONCAT(person.lastName, ‘, ’, person.firstName))");
+	}
+
+	@Test // GH-3588
+	void xxx() {
+
+		Entity entity = JpqlQueryBuilder.entity(Order.class);
+		PathAndOrigin orderDate = JpqlQueryBuilder.path(entity, "date");
+
+		String fragment = JpqlQueryBuilder.where(orderDate).eq(expression("{d '2024-11-05'}")).render(ctx(entity));
+
+		assertThat(fragment).isEqualTo("o.date = {d '2024-11-05'}");
+	}
+
+	@Test // GH-3588
+	void predicateRendering() {
+
+		Entity entity = JpqlQueryBuilder.entity(Order.class);
+		WhereStep where = JpqlQueryBuilder.where(JpqlQueryBuilder.path(entity, "country"));
+		RenderContext context = ctx(entity);
+
+		assertThat(where.between(expression("'AT'"), expression("'DE'")).render(context))
+				.isEqualTo("o.country BETWEEN 'AT' AND 'DE'");
+		assertThat(where.eq(expression("'AT'")).render(context)).isEqualTo("o.country = 'AT'");
+		assertThat(where.eq(literal("AT")).render(context)).isEqualTo("o.country = 'AT'");
+		assertThat(where.gt(expression("'AT'")).render(context)).isEqualTo("o.country > 'AT'");
+		assertThat(where.gte(expression("'AT'")).render(context)).isEqualTo("o.country >= 'AT'");
+
+		// TODO: that is really really bad
+		// lange namen
+		assertThat(where.in(expression("'AT', 'DE'")).render(context)).isEqualTo("o.country IN ('AT', 'DE')");
+
+		// 1 in age - cleanup what is not used - remove everything eles
+		// assertThat(where.inMultivalued("'AT', 'DE'").render(ctx(entity))).isEqualTo("o.country IN ('AT', 'DE')"); //
+		assertThat(where.isEmpty().render(context)).isEqualTo("o.country IS EMPTY");
+		assertThat(where.isNotEmpty().render(context)).isEqualTo("o.country IS NOT EMPTY");
+		assertThat(where.isTrue().render(context)).isEqualTo("o.country = TRUE");
+		assertThat(where.isFalse().render(context)).isEqualTo("o.country = FALSE");
+		assertThat(where.isNull().render(context)).isEqualTo("o.country IS NULL");
+		assertThat(where.isNotNull().render(context)).isEqualTo("o.country IS NOT NULL");
+		assertThat(where.like("'\\_%'", "" + EscapeCharacter.DEFAULT.getEscapeCharacter()).render(context))
+				.isEqualTo("o.country LIKE '\\_%' ESCAPE '\\'");
+		assertThat(where.notLike(expression("'\\_%'"), "" + EscapeCharacter.DEFAULT.getEscapeCharacter()).render(context))
+				.isEqualTo("o.country NOT LIKE '\\_%' ESCAPE '\\'");
+		assertThat(where.lt(expression("'AT'")).render(context)).isEqualTo("o.country < 'AT'");
+		assertThat(where.lte(expression("'AT'")).render(context)).isEqualTo("o.country <= 'AT'");
+		assertThat(where.memberOf(expression("'AT'")).render(context)).isEqualTo("'AT' MEMBER OF o.country");
+		// TODO: can we have this where.value(foo).memberOf(pathAndOrigin);
+		assertThat(where.notMemberOf(expression("'AT'")).render(context)).isEqualTo("'AT' NOT MEMBER OF o.country");
+		assertThat(where.neq(expression("'AT'")).render(context)).isEqualTo("o.country != 'AT'");
+	}
+
+	@Test // GH-3588
+	void selectRendering() {
+
+		// make sure things are immutable
+		SelectStep select = JpqlQueryBuilder.selectFrom(Order.class); // the select step is mutable - not sure i like it
+		// hm, I somehow exepect this to render only the selection part
+		assertThat(select.count().render()).startsWith("SELECT COUNT(o)");
+		assertThat(select.distinct().entity().render()).startsWith("SELECT DISTINCT o ");
+		assertThat(select.distinct().count().render()).startsWith("SELECT COUNT(DISTINCT o) ");
+		assertThat(JpqlQueryBuilder.selectFrom(Order.class)
+				.select(JpqlQueryBuilder.path(JpqlQueryBuilder.entity(Order.class), "country")).render())
+				.startsWith("SELECT o.country ");
+	}
+
+	@Test // GH-3588
+	void joins() {
+
+		Entity entity = JpqlQueryBuilder.entity(LineItem.class);
+		Join li_pr = JpqlQueryBuilder.innerJoin(entity, "product");
+		Join li_pr2 = JpqlQueryBuilder.innerJoin(entity, "product2");
+
+		PathAndOrigin productName = JpqlQueryBuilder.path(li_pr, "name");
+		PathAndOrigin personName = JpqlQueryBuilder.path(li_pr2, "name");
+
+		String fragment = JpqlQueryBuilder.where(productName).eq(literal("ex30"))
+				.and(JpqlQueryBuilder.where(personName).eq(literal("ex40"))).render(ctx(entity));
+
+		assertThat(fragment).isEqualTo("p.name = 'ex30' AND join_0.name = 'ex40'");
+	}
+
+	@Test // GH-3588
+	void joinOnPaths() {
+
+		Entity entity = JpqlQueryBuilder.entity(LineItem.class);
+		Join li_pr = JpqlQueryBuilder.innerJoin(entity, "product");
+		Join li_pe = JpqlQueryBuilder.innerJoin(entity, "person");
+
+		PathAndOrigin productName = JpqlQueryBuilder.path(li_pr, "name");
+		PathAndOrigin personName = JpqlQueryBuilder.path(li_pe, "name");
+
+		String fragment = JpqlQueryBuilder.where(productName).eq(literal("ex30"))
+				.and(JpqlQueryBuilder.where(personName).eq(literal("cstrobl"))).render(ctx(entity));
+
+		assertThat(fragment).isEqualTo("p.name = 'ex30' AND join_0.name = 'cstrobl'");
+	}
+
+	static RenderContext ctx(Entity... entities) {
+
+		Map<Origin, String> aliases = new LinkedHashMap<>(entities.length);
+		for (Entity entity : entities) {
+			aliases.put(entity, entity.getAlias());
+		}
+
+		return new RenderContext(aliases);
+	}
+
+	@jakarta.persistence.Entity
+	static class Order {
+
+		@Id Long id;
+		Date date;
+		String country;
+
+		@OneToMany List<LineItem> lineItems;
+	}
+
+	@jakarta.persistence.Entity
+	static class LineItem {
+
+		@Id Long id;
+
+		@ManyToOne Product product;
+		@ManyToOne Product product2;
+		@ManyToOne Product person;
+
+	}
+
+	@jakarta.persistence.Entity
+	static class Person {
+		@Id Long id;
+		String name;
+	}
+
+	@jakarta.persistence.Entity
+	static class Product {
+
+		@Id Long id;
+
+		String name;
+		String productType;
+
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryRendererTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryRendererTests.java
index a16a5a8802..f84eb18f84 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryRendererTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryRendererTests.java
@@ -70,6 +70,266 @@ private String reduceWhitespace(String original) {
 				.trim();
 	}
 
+	@Test
+	void selectQueries() {
+
+		assertQuery("Select e FROM Employee e WHERE e.salary > 100000");
+		assertQuery("Select e FROM Employee e WHERE e.id = :id");
+		assertQuery("Select MAX(e.salary) FROM Employee e");
+		assertQuery("Select e.firstName FROM Employee e");
+		assertQuery("Select e.firstName, e.lastName FROM Employee e");
+	}
+
+	@Test
+	void selectClause() {
+
+		assertQuery("SELECT COUNT(e) FROM Employee e");
+		assertQuery("SELECT MAX(e.salary) FROM Employee e");
+		assertQuery("SELECT NEW com.acme.reports.EmpReport(e.firstName, e.lastName, e.salary) FROM Employee e");
+	}
+
+	@Test
+	void fromClause() {
+
+		assertQuery("SELECT e FROM Employee e");
+		assertQuery("SELECT e, a FROM Employee e, MailingAddress a WHERE e.address = a.address");
+		assertQuery("SELECT e FROM com.acme.Employee e");
+	}
+
+	@Test
+	void join() {
+
+		assertQuery("SELECT e FROM Employee e JOIN e.address a WHERE a.city = :city");
+		assertQuery("SELECT e FROM Employee e JOIN e.projects p JOIN e.projects p2 WHERE p.name = :p1 AND p2.name = :p2");
+	}
+
+	@Test
+	void joinFetch() {
+
+		assertQuery("SELECT e FROM Employee e JOIN FETCH e.address");
+		assertQuery("SELECT e FROM Employee e JOIN FETCH e.address a ORDER BY a.city");
+		assertQuery("SELECT e FROM Employee e JOIN FETCH e.address AS a ORDER BY a.city");
+	}
+
+	@Test
+	void leftJoin() {
+		assertQuery("SELECT e FROM Employee e LEFT JOIN e.address a ORDER BY a.city");
+	}
+
+	@Test // GH-3277
+	void numericLiterals() {
+
+		assertQuery("SELECT e FROM Employee e WHERE e.id = 1234");
+		assertQuery("SELECT e FROM Employee e WHERE e.id = 1234L");
+		assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14");
+		assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14F");
+		assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14e32D");
+	}
+
+	@Test // GH-3308
+	void newWithStrings() {
+		assertQuery("select new com.example.demo.SampleObject(se.id, se.sampleValue, \"java\") from SampleEntity se");
+	}
+
+	@Test
+	void orderByClause() {
+
+		assertQuery("SELECT e FROM Employee e ORDER BY e.lastName ASC, e.firstName ASC"); // Typo in EQL document
+		assertQuery("SELECT e FROM Employee e LEFT JOIN e.manager m ORDER BY m.lastName NULLS FIRST");
+		assertQuery("SELECT e FROM Employee e ORDER BY e.address");
+	}
+
+	@Test
+	void groupByClause() {
+
+		assertQuery("SELECT AVG(e.salary), e.address.city FROM Employee e GROUP BY e.address.city");
+		assertQuery("SELECT e, COUNT(p) FROM Employee e LEFT JOIN e.projects p GROUP BY e");
+	}
+
+	@Test
+	void havingClause() {
+		assertQuery(
+				"SELECT AVG(e.salary), e.address.city FROM Employee e GROUP BY e.address.city HAVING AVG(e.salary) > 100000");
+	}
+
+	@Test // GH-3136
+	void union() {
+
+		assertQuery("""
+				SELECT MAX(e.salary) FROM Employee e WHERE e.address.city = :city1
+				UNION SELECT MAX(e.salary) FROM Employee e WHERE e.address.city = :city2
+				""");
+		assertQuery("""
+				SELECT e FROM Employee e JOIN e.phones p WHERE p.areaCode = :areaCode1
+				INTERSECT SELECT e FROM Employee e JOIN e.phones p WHERE p.areaCode = :areaCode2
+				""");
+		assertQuery("""
+				SELECT e FROM Employee e
+				EXCEPT SELECT e FROM Employee e WHERE e.salary > e.manager.salary
+				""");
+	}
+
+	@Test
+	void whereClause() {
+		// TBD
+	}
+
+	@Test
+	void updateQueries() {
+		assertQuery("UPDATE Employee e SET e.salary = 60000 WHERE e.salary = 50000");
+	}
+
+	@Test
+	void deleteQueries() {
+		assertQuery("DELETE FROM Employee e WHERE e.department IS NULL");
+	}
+
+	@Test
+	void literals() {
+
+		assertQuery("SELECT e FROM  Employee e WHERE e.name = 'Bob'");
+		assertQuery("SELECT e FROM  Employee e WHERE e.id = 1234");
+		assertQuery("SELECT e FROM  Employee e WHERE e.id = 1234L");
+		assertQuery("SELECT s FROM  Stat s WHERE s.ratio > 3.14F");
+		assertQuery("SELECT s FROM  Stat s WHERE s.ratio > 3.14e32D");
+		assertQuery("SELECT e FROM  Employee e WHERE e.active = TRUE");
+		assertQuery("SELECT e FROM  Employee e WHERE e.startDate = {d'2012-01-03'}");
+		assertQuery("SELECT e FROM  Employee e WHERE e.startTime = {t'09:00:00'}");
+		assertQuery("SELECT e FROM  Employee e WHERE e.version = {ts'2012-01-03 09:00:00.000000001'}");
+		assertQuery("SELECT e FROM  Employee e WHERE e.gender = org.acme.Gender.MALE");
+		assertQuery("UPDATE Employee e SET e.manager = NULL WHERE e.manager = :manager");
+	}
+
+	@Test
+	void functionsInSelect() {
+
+		assertQuery("SELECT e.salary - 1000 FROM Employee e");
+		assertQuery("SELECT e.salary + 1000 FROM Employee e");
+		assertQuery("SELECT e.salary * 2 FROM Employee e");
+		assertQuery("SELECT e.salary * 2.0 FROM Employee e");
+		assertQuery("SELECT e.salary / 2 FROM Employee e");
+		assertQuery("SELECT e.salary / 2.0 FROM Employee e");
+		assertQuery("SELECT ABS(e.salary - e.manager.salary) FROM Employee e");
+		assertQuery(
+				"select e from Employee e where case e.firstName when 'Bob' then 'Robert' when 'Jill' then 'Gillian' else '' end = 'Robert'");
+		assertQuery(
+				"select case when e.firstName = 'Bob' then 'Robert' when e.firstName = 'Jill' then 'Gillian' else '' end from Employee e  where e.firstName = 'Bob' or e.firstName = 'Jill'");
+		assertQuery(
+				"select e from Employee e where case when e.firstName = 'Bob' then 'Robert' when e.firstName = 'Jill' then 'Gillian' else '' end = 'Robert'");
+		assertQuery("SELECT COALESCE(e.salary, 0) FROM Employee e");
+		assertQuery("SELECT CONCAT(e.firstName, ' ', e.lastName) FROM Employee e");
+		assertQuery("SELECT e.name, CURRENT_DATE FROM Employee e");
+		assertQuery("SELECT e.name, CURRENT_TIME FROM Employee e");
+		assertQuery("SELECT e.name, CURRENT_TIMESTAMP FROM Employee e");
+		assertQuery("SELECT LENGTH(e.lastName) FROM Employee e");
+		assertQuery("SELECT LOWER(e.lastName) FROM Employee e");
+		assertQuery("SELECT MOD(e.hoursWorked, 8) FROM Employee e");
+		assertQuery("SELECT NULLIF(e.salary, 0) FROM Employee e");
+		assertQuery("SELECT SQRT(o.RESULT) FROM Output o");
+		assertQuery("SELECT SUBSTRING(e.lastName, 0, 2) FROM Employee e");
+		assertQuery(
+				"SELECT TRIM(TRAILING FROM e.lastName), TRIM(e.lastName), TRIM(LEADING '-' FROM e.lastName) FROM Employee e");
+		assertQuery("SELECT UPPER(e.lastName) FROM Employee e");
+		assertQuery("SELECT CAST(e.salary NUMERIC(10, 2)) FROM Employee e");
+		assertQuery("SELECT EXTRACT(YEAR FROM e.startDate) FROM Employee e");
+	}
+
+	@Test
+	void functionsInWhere() {
+
+		assertQuery("SELECT e FROM Employee e WHERE e.salary - 1000 > 0");
+		assertQuery("SELECT e FROM Employee e WHERE e.salary + 1000 > 0");
+		assertQuery("SELECT e FROM Employee e WHERE e.salary * 2 > 0");
+		assertQuery("SELECT e FROM Employee e WHERE e.salary * 2.0 > 0.0");
+		assertQuery("SELECT e FROM Employee e WHERE e.salary / 2 > 0");
+		assertQuery("SELECT e FROM Employee e WHERE e.salary / 2.0 > 0.0");
+		assertQuery("SELECT e FROM Employee e WHERE ABS(e.salary - e.manager.salary) > 0");
+		assertQuery("SELECT e FROM Employee e WHERE COALESCE(e.salary, 0) > 0");
+		assertQuery("SELECT e FROM Employee e WHERE CONCAT(e.firstName, ' ', e.lastName) = 'Bilbo'");
+		assertQuery("SELECT e FROM Employee e WHERE CURRENT_DATE > CURRENT_TIME");
+		assertQuery("SELECT e FROM Employee e WHERE CURRENT_TIME > CURRENT_TIMESTAMP");
+		assertQuery("SELECT e FROM Employee e WHERE LENGTH(e.lastName) > 0");
+		assertQuery("SELECT e FROM Employee e WHERE LOWER(e.lastName) = 'bilbo'");
+		assertQuery("SELECT e FROM Employee e WHERE MOD(e.hoursWorked, 8) > 0");
+		assertQuery("SELECT e FROM Employee e WHERE SQRT(o.RESULT) > 0.0");
+		assertQuery("SELECT e FROM Employee e WHERE SUBSTRING(e.lastName, 0, 2) = 'Bilbo'");
+		assertQuery("SELECT e FROM Employee e WHERE TRIM(TRAILING FROM e.lastName) = 'Bilbo'");
+		assertQuery("SELECT e FROM Employee e WHERE TRIM(e.lastName) = 'Bilbo'");
+		assertQuery("SELECT e FROM Employee e WHERE TRIM(LEADING '-' FROM e.lastName) = 'Bilbo'");
+		assertQuery("SELECT e FROM Employee e WHERE UPPER(e.lastName) = 'BILBO'");
+		assertQuery("SELECT e FROM Employee e WHERE CAST(e.salary NUMERIC(10, 2)) > 0.0");
+		assertQuery("SELECT e FROM Employee e WHERE EXTRACT(YEAR FROM e.startDate) = '2023'");
+	}
+
+	@Test
+	void specialOperators() {
+
+		assertQuery("SELECT toDo FROM Employee e JOIN e.toDoList toDo WHERE INDEX(toDo) = 1");
+		assertQuery("SELECT p FROM Employee e JOIN e.priorities p WHERE KEY(p) = 'high'");
+		assertQuery("SELECT e FROM Employee e WHERE SIZE(e.managedEmployees) < 2");
+		assertQuery("SELECT e FROM Employee e WHERE e.managedEmployees IS EMPTY");
+		assertQuery("SELECT e FROM Employee e WHERE 'write code' MEMBER OF e.responsibilities");
+		assertQuery("SELECT p FROM Project p WHERE TYPE(p) = LargeProject");
+
+		/**
+		 * NOTE: The following query has been altered to properly align with EclipseLink test code despite NOT matching
+		 * their ref docs. See https://github.com/eclipse-ee4j/eclipselink/issues/1949 for more details.
+		 */
+		assertQuery("SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) p WHERE p.budget > 1000000");
+
+		assertQuery("SELECT p FROM Phone p WHERE FUNCTION('TO_NUMBER', p.areaCode) > 613");
+	}
+
+	@Test // GH-3314
+	void isNullAndIsNotNull() {
+
+		assertQuery("SELECT e FROM Employee e WHERE (e.active IS null OR e.active = true)");
+		assertQuery("SELECT e FROM Employee e WHERE (e.active IS NULL OR e.active = true)");
+		assertQuery("SELECT e FROM Employee e WHERE (e.active IS NOT null OR e.active = true)");
+		assertQuery("SELECT e FROM Employee e WHERE (e.active IS NOT NULL OR e.active = true)");
+	}
+
+	@Test // GH-3136
+	void intersect() {
+
+		assertQuery("""
+				SELECT e FROM Employee e JOIN e.phones p WHERE p.areaCode = :areaCode1
+				INTERSECT SELECT e FROM Employee e JOIN e.phones p WHERE p.areaCode = :areaCode2
+				""");
+	}
+
+	@Test // GH-3136
+	void except() {
+
+		assertQuery("""
+				SELECT e FROM Employee e
+				EXCEPT SELECT e FROM Employee e WHERE e.salary > e.manager.salary
+				""");
+	}
+
+	@ParameterizedTest // GH-3136
+	@ValueSource(strings = { "STRING", "INTEGER", "FLOAT", "DOUBLE" })
+	void cast(String targetType) {
+		assertQuery("SELECT CAST(e.salary AS %s) FROM Employee e".formatted(targetType));
+	}
+
+	@ParameterizedTest // GH-3136
+	@ValueSource(strings = { "LEFT", "RIGHT" })
+	void leftRightStringFunctions(String keyword) {
+		assertQuery("SELECT %s(e.name, 3) FROM Employee e".formatted(keyword));
+	}
+
+	@Test // GH-3136
+	void replaceStringFunctions() {
+		assertQuery("SELECT REPLACE(e.name, 'o', 'a') FROM Employee e");
+		assertQuery("SELECT REPLACE(e.name, ' ', '_') FROM Employee e");
+	}
+
+	@Test // GH-3136
+	void stringConcatWithPipes() {
+		assertQuery("SELECT e.firstname || e.lastname AS name FROM Employee e");
+	}
+
 	/**
 	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
 	 */
@@ -339,6 +599,38 @@ OR TREAT(e AS Contractor).hours > 100
 				    """);
 	}
 
+	@Test // GH-3136
+	void substring() {
+
+		assertQuery("select substring(c.number, 1, 2) " + //
+				"from Call c");
+
+		assertQuery("select substring(c.number, 1) " + //
+				"from Call c");
+	}
+
+	@Test // GH-3136
+	void currentDateFunctions() {
+
+		assertQuery("select CURRENT_DATE " + //
+				"from Call c ");
+
+		assertQuery("select CURRENT_TIME " + //
+				"from Call c ");
+
+		assertQuery("select CURRENT_TIMESTAMP " + //
+				"from Call c ");
+
+		assertQuery("select LOCAL_DATE " + //
+				"from Call c ");
+
+		assertQuery("select LOCAL_TIME " + //
+				"from Call c ");
+
+		assertQuery("select LOCAL_DATETIME " + //
+				"from Call c ");
+	}
+
 	@Test
 	void pathExpressionsNamedParametersExample() {
 
@@ -598,6 +890,14 @@ SELECT c.country, COUNT(c)
 				GROUP BY c.country
 				HAVING COUNT(c) > 30
 				""");
+
+		assertQuery("""
+				SELECT COUNT(f)
+				FROM FooEntity f
+				WHERE f.name IN ('Y', 'Basic', 'Remit')
+							AND f.size = 10
+				HAVING COUNT(f) > 0
+				""");
 	}
 
 	@Test
@@ -1017,6 +1317,59 @@ void powerShouldBeLegalInAQuery() {
 		assertQuery("select e.power.id from MyEntity e");
 	}
 
+	@Test // GH-3136
+	void doublePipeShouldBeValidAsAStringConcatOperator() {
+
+		assertQuery("""
+				select e.name || ' ' || e.title
+				from Employee e
+				""");
+	}
+
+	@Test // GH-3136
+	void combinedSelectStatementsShouldWork() {
+
+		assertQuery("""
+				select e from Employee e where e.last_name = 'Baggins'
+				intersect
+				select e from Employee e where e.first_name = 'Samwise'
+				union
+				select e from Employee e where e.home = 'The Shire'
+				except
+				select e from Employee e where e.home = 'Isengard'
+				""");
+	}
+
+	@Disabled
+	@Test // GH-3136
+	void additionalStringOperationsShouldWork() {
+
+		assertQuery("""
+				select
+					replace(e.name, 'Baggins', 'Proudfeet'),
+					left(e.role, 4),
+					right(e.home, 5),
+					cast(e.distance_from_home, int)
+				from Employee e
+				""");
+	}
+
+	@Test // GH-3136
+	void orderByWithNullsFirstOrLastShouldWork() {
+
+		assertQuery("""
+				select a
+				from Element a
+				order by mutationAm desc nulls first
+				""");
+
+		assertQuery("""
+				select a
+				from Element a
+				order by mutationAm desc nulls last
+				""");
+	}
+
 	@ParameterizedTest // GH-3342
 	@ValueSource(strings = { "select 1 as value from User u", "select -1 as value from User u",
 			"select +1 as value from User u", "select +1 * -100 as value from User u",
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryTransformerTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryTransformerTests.java
index 147477fc2f..39ed9b6d9d 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryTransformerTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryTransformerTests.java
@@ -20,6 +20,7 @@
 import java.util.stream.Stream;
 
 import org.assertj.core.api.SoftAssertions;
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
@@ -28,7 +29,8 @@
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Sort.Order;
 import org.springframework.data.jpa.domain.JpaSort;
-import org.springframework.lang.Nullable;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.query.ReturnedType;
 
 /**
  * Verify that JPQL queries are properly transformed through the {@link JpaQueryEnhancer} and the
@@ -216,13 +218,16 @@ void applySortingAccountsForNewlinesInSubselect() {
 
 		Sort sort = Sort.by(Sort.Order.desc("age"));
 
+
 		assertThat(newParser("""
 				select u
 				from user u
 				where exists (select u2
 				from user u2
 				)
-				""").applySorting(sort)).isEqualToIgnoringWhitespace("""
+				""").rewrite(new DefaultQueryRewriteInformation(sort,
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()))))
+				.isEqualToIgnoringWhitespace("""
 				select u
 				from user u
 				where exists (select u2
@@ -784,6 +789,15 @@ void sortingRecognizesJoinAliases() {
 				""");
 	}
 
+	@Test // GH-3427
+	void sortShouldBeAppendedToFullSelectOnlyInCaseOfSetOperator() {
+
+		String source = "SELECT tb FROM Test tb WHERE (tb.type='A') UNION SELECT tb FROM Test tb WHERE (tb.type='B')";
+		String target = createQueryFor(source, Sort.by("Type").ascending());
+
+		assertThat(target).isEqualTo("SELECT tb FROM Test tb WHERE (tb.type = 'A') UNION SELECT tb FROM Test tb WHERE (tb.type = 'B') order by tb.Type asc");
+	}
+
 	static Stream<Arguments> queriesWithReservedWordsAsIdentifiers() {
 
 		return Stream.of( //
@@ -799,7 +813,8 @@ private void assertCountQuery(String originalQuery, String countQuery) {
 	}
 
 	private String createQueryFor(String query, Sort sort) {
-		return newParser(query).applySorting(sort);
+		return newParser(query).rewrite(new DefaultQueryRewriteInformation(sort,
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())));
 	}
 
 	private String createCountQueryFor(String query) {
@@ -823,6 +838,6 @@ private String projection(String query) {
 	}
 
 	private QueryEnhancer newParser(String query) {
-		return JpaQueryEnhancer.forJpql(DeclaredQuery.of(query, false));
+		return JpaQueryEnhancer.forJpql(query);
 	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlSpecificationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlSpecificationTests.java
deleted file mode 100644
index 289e522455..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlSpecificationTests.java
+++ /dev/null
@@ -1,909 +0,0 @@
-/*
- * Copyright 2022-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.springframework.data.jpa.repository.query;
-
-import static org.assertj.core.api.Assertions.*;
-
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-
-import org.springframework.data.jpa.repository.query.QueryRenderer.TokenRenderer;
-
-/**
- * Tests built around examples of JPQL found in the JPA spec
- * https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc<br/>
- * <br/>
- * IMPORTANT: Purely verifies the parser without any transformations.
- *
- * @author Greg Turnquist
- * @since 3.1
- */
-class JpqlSpecificationTests {
-
-	private static final String SPEC_FAULT = "Disabled due to spec fault> ";
-
-	/**
-	 * Parse the query using {@link HqlParser} then run it through the query-preserving {@link HqlQueryRenderer}.
-	 */
-	private static String parseWithoutChanges(String query) {
-
-		JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser.parseQuery(query);
-
-		return TokenRenderer.render(new JpqlQueryRenderer().visit(parser.getContext()));
-	}
-
-	private void assertQuery(String query) {
-
-		String slimmedDownQuery = reduceWhitespace(query);
-		assertThat(parseWithoutChanges(slimmedDownQuery)).isEqualTo(slimmedDownQuery);
-	}
-
-	private String reduceWhitespace(String original) {
-
-		return original //
-				.replaceAll("[ \\t\\n]{1,}", " ") //
-				.trim();
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
-	 */
-	@Test
-	void joinExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order AS o JOIN o.lineItems AS l
-				WHERE l.shipped = FALSE
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#identification-variables
-	 */
-	@Test
-	void joinExample2() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l JOIN l.product p
-				WHERE p.productType = 'office_supplies'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#range-variable-declarations
-	 */
-	@Test
-	void rangeVariableDeclarations() {
-
-		assertQuery("""
-				SELECT DISTINCT o1
-				FROM Order o1, Order o2
-				WHERE o1.quantity > o2.quantity AND
-				 o2.customer.lastname = 'Smith' AND
-				 o2.customer.firstname = 'John'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample1() {
-
-		assertQuery("""
-				SELECT i.name, VALUE(p)
-				FROM Item i JOIN i.photos p
-				WHERE KEY(p) LIKE '%egret'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample2() {
-
-		assertQuery("""
-				SELECT i.name, p
-				FROM Item i JOIN i.photos p
-				WHERE KEY(p) LIKE '%egret'
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample3() {
-
-		assertQuery("""
-				SELECT p.vendor
-				FROM Employee e JOIN e.contactInfo.phones p
-				""");
-	}
-
-	/**
-	 * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions
-	 */
-	@Test
-	void pathExpressionsExample4() {
-
-		assertQuery("""
-				SELECT p.vendor
-				FROM Employee e JOIN e.contactInfo c JOIN c.phones p
-				WHERE e.contactInfo.address.zipcode = '95054'
-				""");
-	}
-
-	@Test
-	void pathExpressionSyntaxExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT l.product
-				FROM Order AS o JOIN o.lineItems l
-				""");
-	}
-
-	@Test
-	void joinsExample1() {
-
-		assertQuery("""
-				SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize
-				""");
-	}
-
-	@Test
-	void joinsExample2() {
-
-		assertQuery("""
-				SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1
-				""");
-	}
-
-	@Test
-	void joinsInnerExample() {
-
-		assertQuery("""
-				SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1
-				""");
-	}
-
-	@Test
-	void joinsInExample() {
-
-		assertQuery("""
-				SELECT OBJECT(c) FROM Customer c, IN(c.orders) o WHERE c.status = 1
-				""");
-	}
-
-	@Test
-	void doubleJoinExample() {
-
-		assertQuery("""
-				SELECT p.vendor
-				FROM Employee e JOIN e.contactInfo c JOIN c.phones p
-				WHERE c.address.zipcode = '95054'
-				""");
-	}
-
-	@Test
-	void leftJoinExample() {
-
-		assertQuery("""
-				SELECT s.name, COUNT(p)
-				FROM Suppliers s LEFT JOIN s.products p
-				GROUP BY s.name
-				""");
-	}
-
-	@Test
-	void leftJoinOnExample() {
-
-		assertQuery("""
-				SELECT s.name, COUNT(p)
-				FROM Suppliers s LEFT JOIN s.products p
-				    ON p.status = 'inStock'
-				GROUP BY s.name
-				""");
-	}
-
-	@Test
-	void leftJoinWhereExample() {
-
-		assertQuery("""
-				SELECT s.name, COUNT(p)
-				FROM Suppliers s LEFT JOIN s.products p
-				WHERE p.status = 'inStock'
-				GROUP BY s.name
-				""");
-	}
-
-	@Test
-	void leftJoinFetchExample() {
-
-		assertQuery("""
-				SELECT d
-				FROM Department d LEFT JOIN FETCH d.employees
-				WHERE d.deptno = 1
-				""");
-	}
-
-	@Test
-	void collectionMemberExample() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				WHERE l.product.productType = 'office_supplies'
-				""");
-	}
-
-	@Test
-	void collectionMemberInExample() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o, IN(o.lineItems) l
-				WHERE l.product.productType = 'office_supplies'
-				""");
-	}
-
-	@Test
-	void fromClauseExample() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order AS o JOIN o.lineItems l JOIN l.product p
-				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample1() {
-
-		assertQuery("""
-				SELECT b.name, b.ISBN
-				FROM Order o JOIN TREAT(o.product AS Book) b
-				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample2() {
-
-		assertQuery("""
-				SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp
-				WHERE lp.budget > 1000
-				""");
-	}
-
-	/**
-	 * @see #fromClauseDowncastingExample3fixed()
-	 */
-	@Test
-	@Disabled(SPEC_FAULT + "Use double-quotes when it should be using single-quotes for a string literal")
-	void fromClauseDowncastingExample3_SPEC_BUG() {
-
-		assertQuery("""
-				SELECT e FROM Employee e JOIN e.projects p
-				WHERE TREAT(p AS LargeProject).budget > 1000
-				    OR TREAT(p AS SmallProject).name LIKE 'Persist%'
-				    OR p.description LIKE "cost overrun"
-				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample3fixed() {
-
-		assertQuery("""
-				SELECT e FROM Employee e JOIN e.projects p
-				WHERE TREAT(p AS LargeProject).budget > 1000
-				    OR TREAT(p AS SmallProject).name LIKE 'Persist%'
-				    OR p.description LIKE 'cost overrun'
-				""");
-	}
-
-	@Test
-	void fromClauseDowncastingExample4() {
-
-		assertQuery("""
-				SELECT e FROM Employee e
-				WHERE TREAT(e AS Exempt).vacationDays > 10
-				    OR TREAT(e AS Contractor).hours > 100
-				""");
-	}
-
-	@Test
-	void pathExpressionsNamedParametersExample() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE c.status = :stat
-				""");
-	}
-
-	@Test
-	void betweenExpressionsExample() {
-
-		assertQuery("""
-				SELECT t
-				FROM CreditCard c JOIN c.transactionHistory t
-				WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9
-				""");
-	}
-
-	@Test
-	void isEmptyExample() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS EMPTY
-				""");
-	}
-
-	@Test
-	void memberOfExample() {
-
-		assertQuery("""
-				SELECT p
-				FROM Person p
-				WHERE 'Joe' MEMBER OF p.nicknames
-				""");
-	}
-
-	@Test
-	void existsSubSelectExample1() {
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE EXISTS (SELECT spouseEmp
-				    FROM Employee spouseEmp
-				        WHERE spouseEmp = emp.spouse)
-				""");
-	}
-
-	@Test
-	void allExample() {
-
-		assertQuery("""
-				SELECT emp
-				FROM Employee emp
-				WHERE emp.salary > ALL (SELECT m.salary
-				    FROM Manager m
-				    WHERE m.department = emp.department)
-				""");
-	}
-
-	@Test
-	void existsSubSelectExample2() {
-
-		assertQuery("""
-				SELECT DISTINCT emp
-				FROM Employee emp
-				WHERE EXISTS (SELECT spouseEmp
-				    FROM Employee spouseEmp
-				    WHERE spouseEmp = emp.spouse)
-				""");
-	}
-
-	@Test
-	void subselectNumericComparisonExample1() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE (SELECT AVG(o.price) FROM c.orders o) > 100
-				""");
-	}
-
-	@Test
-	void subselectNumericComparisonExample2() {
-
-		assertQuery("""
-				SELECT goodCustomer
-				FROM Customer goodCustomer
-				WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed) / 2.0 FROM Customer c)
-				""");
-	}
-
-	@Test
-	void indexExample() {
-
-		assertQuery("""
-				SELECT w.name
-				FROM Course c JOIN c.studentWaitlist w
-				WHERE c.name = 'Calculus'
-				AND INDEX(w) = 0
-				""");
-	}
-
-	/**
-	 * @see #functionInvocationExampleWithCorrection()
-	 */
-	@Test
-	@Disabled(SPEC_FAULT + "FUNCTION calls needs a comparator")
-	void functionInvocationExample_SPEC_BUG() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit)
-				""");
-	}
-
-	@Test
-	void functionInvocationExampleWithCorrection() {
-
-		assertQuery("""
-				SELECT c
-				FROM Customer c
-				WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE
-				""");
-	}
-
-	@Test
-	void updateCaseExample1() {
-
-		assertQuery("""
-				UPDATE Employee e
-				SET e.salary =
-				    CASE WHEN e.rating = 1 THEN e.salary * 1.1
-				         WHEN e.rating = 2 THEN e.salary * 1.05
-				         ELSE e.salary * 1.01
-				    END
-				""");
-	}
-
-	@Test
-	void updateCaseExample2() {
-
-		assertQuery("""
-				UPDATE Employee e
-				SET e.salary =
-				    CASE e.rating WHEN 1 THEN e.salary * 1.1
-				                  WHEN 2 THEN e.salary * 1.05
-				                  ELSE e.salary * 1.01
-				    END
-				""");
-	}
-
-	@Test
-	void selectCaseExample1() {
-
-		assertQuery("""
-				SELECT e.name,
-				    CASE TYPE(e) WHEN Exempt THEN 'Exempt'
-				                 WHEN Contractor THEN 'Contractor'
-				                 WHEN Intern THEN 'Intern'
-				                 ELSE 'NonExempt'
-				    END
-				FROM Employee e
-				WHERE e.dept.name = 'Engineering'
-				""");
-	}
-
-	@Test
-	void selectCaseExample2() {
-
-		assertQuery("""
-				SELECT e.name,
-				       f.name,
-				       CONCAT(CASE WHEN f.annualMiles > 50000 THEN 'Platinum '
-				                   WHEN f.annualMiles > 25000 THEN 'Gold '
-				                   ELSE ''
-				              END,
-				       'Frequent Flyer')
-				FROM Employee e JOIN e.frequentFlierPlan f
-				""");
-	}
-
-	@Test
-	void theRest() {
-
-		assertQuery("""
-				SELECT e
-				 FROM Employee e
-				 WHERE TYPE(e) IN (Exempt, Contractor)
-				""");
-	}
-
-	@Test
-	void theRest2() {
-
-		assertQuery("""
-				SELECT e
-				    FROM Employee e
-				    WHERE TYPE(e) IN (:empType1, :empType2)
-				""");
-	}
-
-	@Test
-	void theRest3() {
-
-		assertQuery("""
-				SELECT e
-				FROM Employee e
-				WHERE TYPE(e) IN :empTypes
-				""");
-	}
-
-	@Test
-	void theRest4() {
-
-		assertQuery("""
-				SELECT TYPE(e)
-				FROM Employee e
-				WHERE TYPE(e) <> Exempt
-				""");
-	}
-
-	@Test
-	void theRest5() {
-
-		assertQuery("""
-				SELECT c.status, AVG(c.filledOrderCount), COUNT(c)
-				FROM Customer c
-				GROUP BY c.status
-				HAVING c.status IN (1, 2)
-				""");
-	}
-
-	@Test
-	void theRest6() {
-
-		assertQuery("""
-				SELECT c.country, COUNT(c)
-				FROM Customer c
-				GROUP BY c.country
-				HAVING COUNT(c) > 30
-				""");
-	}
-
-	@Test
-	void theRest7() {
-
-		assertQuery("""
-				SELECT c, COUNT(o)
-				FROM Customer c JOIN c.orders o
-				GROUP BY c
-				HAVING COUNT(o) >= 5
-				""");
-	}
-
-	@Test
-	void theRest8() {
-
-		assertQuery("""
-				SELECT c.id, c.status
-				FROM Customer c JOIN c.orders o
-				WHERE o.count > 100
-				""");
-	}
-
-	@Test
-	void theRest9() {
-
-		assertQuery("""
-				SELECT v.location.street, KEY(i).title, VALUE(i)
-				FROM VideoStore v JOIN v.videoInventory i
-				WHERE v.location.zipcode = '94301' AND VALUE(i) > 0
-				""");
-	}
-
-	@Test
-	void theRest10() {
-
-		assertQuery("""
-				SELECT o.lineItems FROM Order AS o
-				""");
-	}
-
-	@Test
-	void theRest11() {
-
-		assertQuery("""
-				SELECT c, COUNT(l) AS itemCount
-				FROM Customer c JOIN c.Orders o JOIN o.lineItems l
-				WHERE c.address.state = 'CA'
-				GROUP BY c
-				ORDER BY itemCount
-				""");
-	}
-
-	@Test
-	void theRest12() {
-
-		assertQuery("""
-				SELECT NEW com.acme.example.CustomerDetails(c.id, c.status, o.count)
-				FROM Customer c JOIN c.orders o
-				WHERE o.count > 100
-				""");
-	}
-
-	@Test
-	void theRest13() {
-
-		assertQuery("""
-				SELECT e.address AS addr
-				FROM Employee e
-				""");
-	}
-
-	@Test
-	void theRest14() {
-
-		assertQuery("""
-				SELECT AVG(o.quantity) FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest15() {
-
-		assertQuery("""
-				SELECT SUM(l.price)
-				FROM Order o JOIN o.lineItems l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				""");
-	}
-
-	@Test
-	void theRest16() {
-
-		assertQuery("""
-				SELECT COUNT(o) FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest17() {
-
-		assertQuery("""
-				SELECT COUNT(l.price)
-				FROM Order o JOIN o.lineItems l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				""");
-	}
-
-	@Test
-	void theRest18() {
-
-		assertQuery("""
-				SELECT COUNT(l)
-				FROM Order o JOIN o.lineItems l JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John' AND l.price IS NOT NULL
-				""");
-	}
-
-	@Test
-	void theRest19() {
-
-		assertQuery("""
-				SELECT o
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA'
-				ORDER BY o.quantity DESC, o.totalcost
-				""");
-	}
-
-	@Test
-	void theRest20() {
-
-		assertQuery("""
-				SELECT o.quantity, a.zipcode
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA'
-				ORDER BY o.quantity, a.zipcode
-				""");
-	}
-
-	@Test
-	void theRest21() {
-
-		assertQuery("""
-				SELECT o.quantity, o.cost * 1.08 AS taxedCost, a.zipcode
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA' AND a.county = 'Santa Clara'
-				ORDER BY o.quantity, taxedCost, a.zipcode
-				""");
-	}
-
-	@Test
-	void theRest22() {
-
-		assertQuery("""
-				SELECT AVG(o.quantity) as q, a.zipcode
-				FROM Customer c JOIN c.orders o JOIN c.address a
-				WHERE a.state = 'CA'
-				GROUP BY a.zipcode
-				ORDER BY q DESC
-				""");
-	}
-
-	@Test
-	void theRest23() {
-
-		assertQuery("""
-				SELECT p.product_name
-				FROM Order o JOIN o.lineItems l JOIN l.product p JOIN o.customer c
-				WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-				ORDER BY p.price
-				""");
-	}
-
-	/**
-	 * This query is specifically dubbed illegal in the spec. It may actually be failing for a different reason.
-	 */
-	@Test
-	void theRest24() {
-
-		assertThatExceptionOfType(BadJpqlGrammarException.class).isThrownBy(() -> {
-			assertQuery("""
-					SELECT p.product_name
-					FROM Order o, IN(o.lineItems) l JOIN o.customer c
-					WHERE c.lastname = 'Smith' AND c.firstname = 'John'
-					ORDER BY o.quantity
-					""");
-		});
-	}
-
-	@Test
-	void theRest25() {
-
-		assertQuery("""
-				DELETE
-				FROM Customer c
-				WHERE c.status = 'inactive'
-				""");
-	}
-
-	@Test
-	void theRest26() {
-
-		assertQuery("""
-				DELETE
-				FROM Customer c
-				WHERE c.status = 'inactive'
-				AND c.orders IS EMPTY
-				""");
-	}
-
-	@Test
-	void theRest27() {
-
-		assertQuery("""
-				UPDATE Customer c
-				SET c.status = 'outstanding'
-				WHERE c.balance < 10000
-				""");
-	}
-
-	@Test
-	void theRest28() {
-
-		assertQuery("""
-				UPDATE Employee e
-				SET e.address.building = 22
-				WHERE e.address.building = 14
-				AND e.address.city = 'Santa Clara'
-				AND e.project = 'Jakarta EE'
-				""");
-	}
-
-	@Test
-	void theRest29() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest30() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.shippingAddress.state = 'CA'
-				""");
-	}
-
-	@Test
-	void theRest31() {
-
-		assertQuery("""
-				SELECT DISTINCT o.shippingAddress.state
-				FROM Order o
-				""");
-	}
-
-	@Test
-	void theRest32() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				""");
-	}
-
-	@Test
-	void theRest33() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS NOT EMPTY
-				""");
-	}
-
-	@Test
-	void theRest34() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.lineItems IS EMPTY
-				""");
-	}
-
-	@Test
-	void theRest35() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				WHERE l.shipped = FALSE
-				""");
-	}
-
-	@Test
-	void theRest36() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE
-				NOT (o.shippingAddress.state = o.billingAddress.state AND
-				o.shippingAddress.city = o.billingAddress.city AND
-				o.shippingAddress.street = o.billingAddress.street)
-				""");
-	}
-
-	@Test
-	void theRest37() {
-
-		assertQuery("""
-				SELECT o
-				FROM Order o
-				WHERE o.shippingAddress <> o.billingAddress
-				""");
-	}
-
-	@Test
-	void theRest38() {
-
-		assertQuery("""
-				SELECT DISTINCT o
-				FROM Order o JOIN o.lineItems l
-				WHERE l.product.name = ?1
-				""");
-	}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java
index 6f1692142d..d438cdf9a6 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java
@@ -18,6 +18,7 @@
 import static jakarta.persistence.TemporalType.*;
 import static java.util.Arrays.*;
 import static org.mockito.Mockito.*;
+import static org.springframework.data.jpa.repository.query.QueryParameterSetter.*;
 import static org.springframework.data.jpa.repository.query.QueryParameterSetter.ErrorHandling.*;
 
 import jakarta.persistence.Parameter;
@@ -34,7 +35,8 @@
 import org.assertj.core.api.SoftAssertions;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.springframework.data.jpa.repository.query.QueryParameterSetter.NamedOrIndexedQueryParameterSetter;
+
+import org.springframework.data.jpa.repository.query.QueryParameterSetter.*;
 
 /**
  * Unit tests fir {@link NamedOrIndexedQueryParameterSetter}.
@@ -79,7 +81,7 @@ void strictErrorHandlingThrowsExceptionForAllVariationsOfParameters() {
 		for (Parameter parameter : parameters) {
 			for (TemporalType temporalType : temporalTypes) {
 
-				NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( //
+				QueryParameterSetter setter = QueryParameterSetter.create( //
 						firstValueExtractor, //
 						parameter, //
 						temporalType //
@@ -108,7 +110,7 @@ void lenientErrorHandlingThrowsNoExceptionForAllVariationsOfParameters() {
 		for (Parameter<?> parameter : parameters) {
 			for (TemporalType temporalType : temporalTypes) {
 
-				NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( //
+				QueryParameterSetter setter = QueryParameterSetter.create( //
 						firstValueExtractor, //
 						parameter, //
 						temporalType //
@@ -141,7 +143,7 @@ void lenientSetsParameterWhenSuccessIsUnsure() {
 
 		for (TemporalType temporalType : temporalTypes) {
 
-			NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( //
+			QueryParameterSetter setter = QueryParameterSetter.create( //
 					firstValueExtractor, //
 					new ParameterImpl(null, 11), // parameter position is beyond number of parametes in query (0)
 					temporalType //
@@ -171,7 +173,7 @@ void parameterNotSetWhenSuccessImpossible() {
 
 		for (TemporalType temporalType : temporalTypes) {
 
-			NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( //
+			QueryParameterSetter setter = QueryParameterSetter.create( //
 					firstValueExtractor, //
 					new ParameterImpl(null, null), // no position (and no name) makes a success of a setParameter impossible
 					temporalType //
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedQueryUnitTests.java
index 68cae8bc60..71bd266f05 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedQueryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedQueryUnitTests.java
@@ -41,6 +41,7 @@
 import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
 import org.springframework.data.repository.core.RepositoryMetadata;
 import org.springframework.data.repository.query.QueryCreationException;
+import org.springframework.data.repository.query.ValueExpressionDelegate;
 import org.springframework.data.util.TypeInformation;
 
 /**
@@ -55,6 +56,9 @@
 @MockitoSettings(strictness = Strictness.LENIENT)
 class NamedQueryUnitTests {
 
+	private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(),
+			QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT);
+
 	@Mock RepositoryMetadata metadata;
 	@Mock QueryExtractor extractor;
 	@Mock EntityManager em;
@@ -90,7 +94,7 @@ void rejectsPersistenceProviderIfIncapableOfExtractingQueriesAndPagebleBeingUsed
 
 		when(em.createNamedQuery(queryMethod.getNamedCountQueryName())).thenThrow(new IllegalArgumentException());
 		assertThatExceptionOfType(QueryCreationException.class)
-				.isThrownBy(() -> NamedQuery.lookupFrom(queryMethod, em, QueryRewriter.IdentityQueryRewriter.INSTANCE));
+				.isThrownBy(() -> NamedQuery.lookupFrom(queryMethod, em, CONFIG));
 	}
 
 	@Test // DATAJPA-142
@@ -102,8 +106,7 @@ void doesNotRejectPersistenceProviderIfNamedCountQueryIsAvailable() {
 
 		TypedQuery<Long> countQuery = mock(TypedQuery.class);
 		when(em.createNamedQuery(eq(queryMethod.getNamedCountQueryName()), eq(Long.class))).thenReturn(countQuery);
-		NamedQuery query = (NamedQuery) NamedQuery.lookupFrom(queryMethod, em,
-				QueryRewriter.IdentityQueryRewriter.INSTANCE);
+		NamedQuery query = (NamedQuery) NamedQuery.lookupFrom(queryMethod, em, CONFIG);
 
 		query.doCreateCountQuery(new JpaParametersParameterAccessor(queryMethod.getParameters(), new Object[1]));
 		verify(em, times(1)).createNamedQuery(queryMethod.getNamedCountQueryName(), Long.class);
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java
index cf9dab51fb..c17cc49f94 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java
@@ -30,11 +30,9 @@
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 
-import org.springframework.core.annotation.AnnotatedElementUtils;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jpa.provider.QueryExtractor;
 import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.jpa.repository.QueryRewriter;
 import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
 import org.springframework.data.repository.Repository;
 import org.springframework.data.repository.core.RepositoryMetadata;
@@ -72,13 +70,14 @@ void shouldApplySorting() {
 		JpaQueryMethod queryMethod = new JpaQueryMethod(respositoryMethod, repositoryMetadata, projectionFactory,
 				queryExtractor);
 
-		Query annotation = AnnotatedElementUtils.getMergedAnnotation(respositoryMethod, Query.class);
+		NativeJpaQuery query = new NativeJpaQuery(queryMethod, em, queryMethod.getRequiredDeclaredQuery(),
+				queryMethod.getDeclaredCountQuery(),
+				new JpaQueryConfiguration(QueryRewriterProvider.simple(), QueryEnhancerSelector.DEFAULT_SELECTOR,
+						ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT));
+		QueryProvider sql = query.getSortedQuery(Sort.by("foo", "bar"),
+				queryMethod.getResultProcessor().getReturnedType());
 
-		NativeJpaQuery query = new NativeJpaQuery(queryMethod, em, annotation.value(), annotation.countQuery(),
-				QueryRewriter.IdentityQueryRewriter.INSTANCE, ValueExpressionDelegate.create());
-		String sql = query.getSortedQueryString(Sort.by("foo", "bar"), queryMethod.getResultProcessor().getReturnedType());
-
-		assertThat(sql).isEqualTo("SELECT e FROM Employee e order by e.foo asc, e.bar asc");
+		assertThat(sql.getQueryString()).isEqualTo("SELECT e FROM Employee e order by e.foo asc, e.bar asc");
 	}
 
 	interface TestRepo extends Repository<Object, Object> {
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java
index e80d9a8692..360dcf4be1 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java
@@ -274,13 +274,13 @@ private void bind(Method method, Object[] values) {
 	}
 
 	private void bind(Method method, JpaParameters parameters, Object[] values) {
-		ParameterBinderFactory.createBinder(parameters).bind(QueryParameterSetter.BindableQuery.from(query),
+		ParameterBinderFactory.createBinder(parameters, false).bind(QueryParameterSetter.BindableQuery.from(query),
 				getAccessor(method, values), QueryParameterSetter.ErrorHandling.STRICT);
 	}
 
 	private void bindAndPrepare(Method method, Object[] values) {
-		ParameterBinderFactory.createBinder(createParameters(method)).bindAndPrepare(query,
-				new QueryParameterSetter.QueryMetadata(query), getAccessor(method, values));
+		ParameterBinderFactory.createBinder(createParameters(method), false).bindAndPrepare(query,
+				getAccessor(method, values));
 	}
 
 	private JpaParametersParameterAccessor getAccessor(Method method, Object... values) {
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java
index edcaf0e4ea..f765860a27 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java
@@ -18,7 +18,6 @@
 import org.assertj.core.api.SoftAssertions;
 import org.junit.jupiter.api.Disabled;
 import org.junit.jupiter.api.Test;
-import org.springframework.data.jpa.repository.query.StringQuery.ParameterBindingParser;
 
 /**
  * Unit tests for the {@link ParameterBindingParser}.
@@ -68,7 +67,7 @@ void identificationOfParameters() {
 
 	private void checkHasParameter(SoftAssertions softly, String query, boolean containsParameter, String label) {
 
-		StringQuery stringQuery = new StringQuery(query, false);
+		DefaultEntityQuery stringQuery = new TestEntityQuery(query, false);
 
 		softly.assertThat(stringQuery.getParameterBindings().size()) //
 				.describedAs(String.format("<%s> (%s)", query, label)) //
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java
deleted file mode 100644
index b706551305..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * Copyright 2017-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.springframework.data.jpa.repository.query;
-
-import static org.assertj.core.api.Assertions.*;
-
-import jakarta.persistence.EntityManager;
-import jakarta.persistence.PersistenceContext;
-import jakarta.persistence.criteria.CriteriaBuilder;
-import jakarta.persistence.criteria.ParameterExpression;
-
-import java.lang.reflect.Method;
-
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.springframework.data.jpa.domain.sample.User;
-import org.springframework.data.repository.query.DefaultParameters;
-import org.springframework.data.repository.query.Parameters;
-import org.springframework.data.repository.query.ParametersParameterAccessor;
-import org.springframework.data.repository.query.ParametersSource;
-import org.springframework.data.repository.query.parser.Part;
-import org.springframework.test.context.ContextConfiguration;
-import org.springframework.test.context.junit.jupiter.SpringExtension;
-
-/**
- * Integration tests for {@link ParameterMetadataProvider}.
- *
- * @author Oliver Gierke
- * @author Jens Schauder
- */
-@ExtendWith(SpringExtension.class)
-@ContextConfiguration("classpath:infrastructure.xml")
-class ParameterExpressionProviderTests {
-
-	@PersistenceContext EntityManager em;
-
-	@Test // DATADOC-99
-	@SuppressWarnings("rawtypes")
-	void createsParameterExpressionWithMostConcreteType() throws Exception {
-
-		Method method = SampleRepository.class.getMethod("findByIdGreaterThan", int.class);
-		Parameters<?, ?> parameters = new DefaultParameters(ParametersSource.of(method));
-		ParametersParameterAccessor accessor = new ParametersParameterAccessor(parameters, new Object[] { 1 });
-		Part part = new Part("IdGreaterThan", User.class);
-
-		CriteriaBuilder builder = em.getCriteriaBuilder();
-		ParameterMetadataProvider provider = new ParameterMetadataProvider(builder, accessor, EscapeCharacter.DEFAULT);
-		ParameterExpression<? extends Comparable> expression = provider.next(part, Comparable.class).getExpression();
-
-		assertThat(expression.getParameterType()).isEqualTo(Integer.class);
-	}
-
-	interface SampleRepository {
-
-		User findByIdGreaterThan(int id);
-	}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java
index c0f86397d3..81e454c799 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java
@@ -27,7 +27,7 @@
 import org.junit.jupiter.api.extension.ExtendWith;
 
 import org.springframework.data.jpa.domain.sample.User;
-import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata;
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
 import org.springframework.data.repository.query.Param;
 import org.springframework.data.repository.query.Parameters;
 import org.springframework.data.repository.query.ParametersSource;
@@ -50,30 +50,32 @@ class ParameterMetadataProviderIntegrationTests {
 	@PersistenceContext EntityManager em;
 
 	@Test // DATAJPA-758
-	void forwardsParameterNameIfTransparentlyNamed() throws Exception {
+	void usesNamedParametersForExplicitlyNamedParameters() throws Exception {
 
 		ParameterMetadataProvider provider = createProvider(Sample.class.getMethod("findByFirstname", String.class));
-		ParameterMetadata<Object> metadata = provider.next(new Part("firstname", User.class));
+		ParameterBinding.PartTreeParameterBinding metadata = provider.next(new Part("firstname", User.class));
 
-		assertThat(metadata.getExpression().getName()).isEqualTo("name");
+		assertThat(metadata.getName()).isEqualTo("name");
+		assertThat(metadata.getPosition()).isEqualTo(1);
 	}
 
 	@Test // DATAJPA-758
-	void forwardsParameterNameIfExplicitlyAnnotated() throws Exception {
+	void usesNamedParameters() throws Exception {
 
 		ParameterMetadataProvider provider = createProvider(Sample.class.getMethod("findByLastname", String.class));
-		ParameterMetadata<Object> metadata = provider.next(new Part("lastname", User.class));
+		ParameterBinding.PartTreeParameterBinding metadata = provider.next(new Part("lastname", User.class));
 
-		assertThat(metadata.getExpression().getName()).isNull();
+		assertThat(metadata.getName()).isEqualTo("lastname");
+		assertThat(metadata.getPosition()).isEqualTo(1);
 	}
 
 	@Test // DATAJPA-772
 	void doesNotApplyLikeExpansionOnNonStringProperties() throws Exception {
 
 		ParameterMetadataProvider provider = createProvider(Sample.class.getMethod("findByAgeContaining", Integer.class));
-		ParameterMetadata<Object> metadata = provider.next(new Part("ageContaining", User.class));
+		ParameterBinding.PartTreeParameterBinding binding = provider.next(new Part("ageContaining", User.class));
 
-		assertThat(metadata.prepare(1)).isEqualTo(1);
+		assertThat(binding.prepare(1)).isEqualTo(1);
 	}
 
 	private ParameterMetadataProvider createProvider(Method method) {
@@ -81,7 +83,8 @@ private ParameterMetadataProvider createProvider(Method method) {
 		JpaParameters parameters = new JpaParameters(ParametersSource.of(method));
 		simulateDiscoveredParametername(parameters);
 
-		return new ParameterMetadataProvider(em.getCriteriaBuilder(), parameters, EscapeCharacter.DEFAULT);
+		return new ParameterMetadataProvider(parameters, EscapeCharacter.DEFAULT,
+				JpqlQueryTemplates.UPPER);
 	}
 
 	@SuppressWarnings({ "unchecked", "ConstantConditions" })
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderUnitTests.java
index 86a4de3ab2..4ad41bfd14 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderUnitTests.java
@@ -18,8 +18,6 @@
 import static org.assertj.core.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
-import jakarta.persistence.criteria.CriteriaBuilder;
-
 import java.util.Collections;
 
 import org.eclipse.persistence.internal.jpa.querydef.ParameterExpressionImpl;
@@ -30,7 +28,8 @@
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
-import org.springframework.data.repository.query.Parameters;
+
+import org.springframework.data.jpa.repository.support.JpqlQueryTemplates;
 import org.springframework.data.repository.query.parser.Part;
 
 /**
@@ -51,13 +50,11 @@ class ParameterMetadataProviderUnitTests {
 	@Test // DATAJPA-863
 	void errorMessageMentionsParametersWhenParametersAreExhausted() {
 
-		CriteriaBuilder builder = mock(CriteriaBuilder.class);
-
-		Parameters<?, ?> parameters = mock(Parameters.class, RETURNS_DEEP_STUBS);
+		JpaParameters parameters = mock(JpaParameters.class, RETURNS_DEEP_STUBS);
 		when(parameters.getBindableParameters().iterator()).thenReturn(Collections.emptyListIterator());
 
-		ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(builder, parameters,
-				EscapeCharacter.DEFAULT);
+		ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters,
+				EscapeCharacter.DEFAULT, JpqlQueryTemplates.UPPER);
 
 		assertThatExceptionOfType(RuntimeException.class) //
 				.isThrownBy(() -> metadataProvider.next(mock(Part.class))) //
@@ -68,6 +65,7 @@ void errorMessageMentionsParametersWhenParametersAreExhausted() {
 	void returnAugmentedValueForStringExpressions() {
 
 		when(part.getProperty().getLeafProperty().isCollection()).thenReturn(false);
+		when(part.getProperty().getType()).thenReturn((Class) String.class);
 
 		assertThat(createParameterMetadata(Part.Type.STARTING_WITH).prepare("starting with")).isEqualTo("starting with%");
 		assertThat(createParameterMetadata(Part.Type.ENDING_WITH).prepare("ending with")).isEqualTo("%ending with");
@@ -82,6 +80,6 @@ void returnAugmentedValueForStringExpressions() {
 	private ParameterMetadataProvider.ParameterMetadata createParameterMetadata(Part.Type partType) {
 
 		when(part.getType()).thenReturn(partType);
-		return new ParameterMetadataProvider.ParameterMetadata<>(parameterExpression, part, null, EscapeCharacter.DEFAULT);
+		return new ParameterMetadataProvider.ParameterMetadata(part.getProperty().getType(), part, null, EscapeCharacter.DEFAULT, 1, JpqlQueryTemplates.LOWER);
 	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeJpaQueryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeJpaQueryIntegrationTests.java
index 604864545b..02d63e6770 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeJpaQueryIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeJpaQueryIntegrationTests.java
@@ -17,9 +17,7 @@
  */
 package org.springframework.data.jpa.repository.query;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.*;
 
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.PersistenceContext;
@@ -39,9 +37,11 @@
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.PageRequest;
 import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.domain.sample.Role;
 import org.springframework.data.jpa.domain.sample.User;
 import org.springframework.data.jpa.provider.HibernateUtils;
 import org.springframework.data.jpa.provider.PersistenceProvider;
@@ -112,7 +112,7 @@ void recreatesQueryIfNullValueIsGiven(String criteria) throws Exception {
 
 		Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { "Matthews", PageRequest.of(0, 1) }));
 		assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY)))
-				.contains("firstname %s :".formatted(criteria.endsWith("Not") ? "<>" : "="));
+				.contains("firstname %s :".formatted(criteria.endsWith("Not") ? "!=" : "="));
 
 		query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { null, PageRequest.of(0, 1) }));
 
@@ -151,7 +151,7 @@ void isEmptyCollection() throws Exception {
 
 		Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] {}));
 
-		assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("roles is empty");
+		assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("roles IS EMPTY");
 	}
 
 	@Test // DATAJPA-1074, HHH-15432
@@ -162,7 +162,18 @@ void isNotEmptyCollection() throws Exception {
 
 		Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] {}));
 
-		assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("roles is not empty");
+		assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("roles IS NOT EMPTY");
+	}
+
+	@Test //
+	void containingCollection() throws Exception {
+
+		JpaQueryMethod queryMethod = getQueryMethod("findByRolesContaining", Role.class);
+		PartTreeJpaQuery jpaQuery = new PartTreeJpaQuery(queryMethod, entityManager);
+
+		Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { new Role() }));
+
+		assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("MEMBER OF u.roles");
 	}
 
 	@Test // DATAJPA-1074
@@ -170,7 +181,8 @@ void rejectsIsEmptyOnNonCollectionProperty() throws Exception {
 
 		JpaQueryMethod method = getQueryMethod("findByFirstnameIsEmpty");
 
-		assertThatIllegalArgumentException().isThrownBy(() -> new PartTreeJpaQuery(method, entityManager));
+		assertThatIllegalArgumentException().isThrownBy(
+				() -> new PartTreeJpaQuery(method, entityManager).createQuery(getAccessor(method, new Object[] {})));
 	}
 
 	@Test // DATAJPA-1182
@@ -297,6 +309,8 @@ interface UserRepository extends Repository<User, Integer> {
 
 		List<User> findByFirstnameIsEmpty();
 
+		List<User> findByRolesContaining(Role role);
+
 		// should fail, since we can't compare scalar values to collections
 		List<User> findById(Collection<Integer> ids);
 
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeQueryCacheUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeQueryCacheUnitTests.java
new file mode 100644
index 0000000000..e55d89bfd1
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/PartTreeQueryCacheUnitTests.java
@@ -0,0 +1,116 @@
+/*
+ * 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.springframework.data.jpa.repository.query;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.FieldSource;
+import org.mockito.Mockito;
+import org.springframework.data.domain.Sort;
+import org.springframework.data.domain.Sort.Direction;
+
+/**
+ * @author Christoph Strobl
+ */
+public class PartTreeQueryCacheUnitTests {
+
+	PartTreeQueryCache cache;
+
+	static Supplier<Stream<Arguments>> cacheInput = () -> Stream.of(
+			Arguments.arguments(Sort.unsorted(), StubJpaParameterParameterAccessor.accessor()), //
+			Arguments.arguments(Sort.by(Direction.ASC, "one"), StubJpaParameterParameterAccessor.accessor()), //
+			Arguments.arguments(Sort.by(Direction.DESC, "one"), StubJpaParameterParameterAccessor.accessor()), //
+			Arguments.arguments(Sort.unsorted(),
+					StubJpaParameterParameterAccessor.accessorFor(String.class).withValues("value")), //
+			Arguments.arguments(Sort.unsorted(),
+					StubJpaParameterParameterAccessor.accessorFor(String.class).withValues(new Object[] { null })), //
+			Arguments.arguments(Sort.by(Direction.ASC, "one"),
+					StubJpaParameterParameterAccessor.accessorFor(String.class).withValues("value")), //
+			Arguments.arguments(Sort.by(Direction.ASC, "one"),
+					StubJpaParameterParameterAccessor.accessorFor(String.class).withValues(new Object[] { null })));
+
+	@BeforeEach
+	void beforeEach() {
+		cache = new PartTreeQueryCache();
+	}
+
+	@ParameterizedTest
+	@FieldSource("cacheInput")
+	void getReturnsNullForEmptyCache(Sort sort, JpaParametersParameterAccessor accessor) {
+		assertThat(cache.get(sort, accessor)).isNull();
+	}
+
+	@ParameterizedTest
+	@FieldSource("cacheInput")
+	void getReturnsCachedInstance(Sort sort, JpaParametersParameterAccessor accessor) {
+
+		JpaQueryCreator queryCreator = Mockito.mock(JpaQueryCreator.class);
+
+		assertThat(cache.put(sort, accessor, queryCreator)).isNull();
+		assertThat(cache.get(sort, accessor)).isSameAs(queryCreator);
+	}
+
+	@ParameterizedTest
+	@FieldSource("cacheInput")
+	void cacheGetWithSort(Sort sort, JpaParametersParameterAccessor accessor) {
+
+		JpaQueryCreator queryCreator = Mockito.mock(JpaQueryCreator.class);
+		assertThat(cache.put(Sort.by("not-in-cache"), accessor, queryCreator)).isNull();
+
+		assertThat(cache.get(sort, accessor)).isNull();
+	}
+
+	@ParameterizedTest
+	@FieldSource("cacheInput")
+	void cacheGetWithccessor(Sort sort, JpaParametersParameterAccessor accessor) {
+
+		JpaQueryCreator queryCreator = Mockito.mock(JpaQueryCreator.class);
+		assertThat(cache.put(sort, StubJpaParameterParameterAccessor.accessor("spring", "data"), queryCreator)).isNull();
+
+		assertThat(cache.get(sort, accessor)).isNull();
+	}
+
+	@Test
+	void cachesOnNullableNotArgumentType() {
+
+		JpaQueryCreator queryCreator = Mockito.mock(JpaQueryCreator.class);
+		Sort sort = Sort.unsorted();
+		assertThat(cache.put(sort, StubJpaParameterParameterAccessor.accessor("spring", "data"), queryCreator)).isNull();
+
+		assertThat(cache.get(sort,
+				StubJpaParameterParameterAccessor.accessor(new Class[] { String.class, String.class }, "spring", null)))
+				.isNull();
+
+		assertThat(cache.get(sort,
+				StubJpaParameterParameterAccessor.accessor(new Class[] { String.class, String.class }, null, "data"))).isNull();
+
+		assertThat(cache.get(sort,
+				StubJpaParameterParameterAccessor.accessor(new Class[] { String.class, String.class }, "data", "spring")))
+				.isSameAs(queryCreator);
+
+		assertThat(cache.get(Sort.by("not-in-cache"),
+				StubJpaParameterParameterAccessor.accessor(new Class[] { String.class, String.class }, "data", "spring")))
+				.isNull();
+	}
+
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactoryUnitTests.java
index 99b8a7a730..2f52341214 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactoryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactoryUnitTests.java
@@ -17,16 +17,7 @@
 
 import static org.assertj.core.api.Assertions.*;
 
-import java.util.stream.Stream;
-
 import org.junit.jupiter.api.Test;
-import org.junit.jupiter.params.ParameterizedTest;
-import org.junit.jupiter.params.provider.Arguments;
-import org.junit.jupiter.params.provider.MethodSource;
-
-import org.springframework.data.jpa.repository.query.QueryEnhancerFactory.NativeQueryEnhancer;
-import org.springframework.data.jpa.util.ClassPathExclusions;
-import org.springframework.lang.Nullable;
 
 /**
  * Unit tests for {@link QueryEnhancerFactory}.
@@ -41,9 +32,10 @@ class QueryEnhancerFactoryUnitTests {
 	@Test
 	void createsParsingImplementationForNonNativeQuery() {
 
-		StringQuery query = new StringQuery("select new com.example.User(u.firstname) from User u", false);
+		DefaultEntityQuery query = new TestEntityQuery("select new com.example.User(u.firstname) from User u",
+				false);
 
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query);
+		QueryEnhancer queryEnhancer = QueryEnhancer.create(query);
 
 		assertThat(queryEnhancer) //
 				.isInstanceOf(JpaQueryEnhancer.class);
@@ -56,81 +48,12 @@ void createsParsingImplementationForNonNativeQuery() {
 	@Test
 	void createsJSqlImplementationForNativeQuery() {
 
-		StringQuery query = new StringQuery("select * from User", true);
+		DefaultEntityQuery query = new TestEntityQuery("select * from User", true);
 
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query);
+		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query);
 
 		assertThat(queryEnhancer) //
 				.isInstanceOf(JSqlParserQueryEnhancer.class);
 	}
 
-	@ParameterizedTest // GH-2989
-	@MethodSource("nativeEnhancerSelectionArgs")
-	void createsNativeImplementationAccordingToUserChoice(@Nullable String selection, NativeQueryEnhancer enhancer) {
-
-		assertThat(NativeQueryEnhancer.JSQLPARSER_PRESENT).isTrue();
-
-		withSystemProperty(NativeQueryEnhancer.NATIVE_PARSER_PROPERTY, selection, () -> {
-			assertThat(NativeQueryEnhancer.select()).isEqualTo(enhancer);
-		});
-	}
-
-	static Stream<Arguments> nativeEnhancerSelectionArgs() {
-		return Stream.of(Arguments.of(null, NativeQueryEnhancer.JSQLPARSER), //
-				Arguments.of("", NativeQueryEnhancer.JSQLPARSER), //
-				Arguments.of("auto", NativeQueryEnhancer.JSQLPARSER), //
-				Arguments.of("regex", NativeQueryEnhancer.REGEX), //
-				Arguments.of("jsqlparser", NativeQueryEnhancer.JSQLPARSER));
-	}
-
-	@ParameterizedTest // GH-2989
-	@MethodSource("nativeEnhancerExclusionSelectionArgs")
-	@ClassPathExclusions(packages = { "net.sf.jsqlparser.parser" })
-	void createsNativeImplementationAccordingWithoutJsqlParserToUserChoice(@Nullable String selection,
-			NativeQueryEnhancer enhancer) {
-
-		assertThat(NativeQueryEnhancer.JSQLPARSER_PRESENT).isFalse();
-
-		withSystemProperty(NativeQueryEnhancer.NATIVE_PARSER_PROPERTY, selection, () -> {
-			assertThat(NativeQueryEnhancer.select()).isEqualTo(enhancer);
-		});
-	}
-
-	static Stream<Arguments> nativeEnhancerExclusionSelectionArgs() {
-		return Stream.of(Arguments.of(null, NativeQueryEnhancer.REGEX), //
-				Arguments.of("", NativeQueryEnhancer.REGEX), //
-				Arguments.of("auto", NativeQueryEnhancer.REGEX), //
-				Arguments.of("regex", NativeQueryEnhancer.REGEX), //
-				Arguments.of("jsqlparser", NativeQueryEnhancer.JSQLPARSER));
-	}
-
-	@Test // GH-2989
-	@ClassPathExclusions(packages = { "net.sf.jsqlparser.parser" })
-	void selectedDefaultImplementationIfJsqlNotAvailable() {
-
-		assertThat(NativeQueryEnhancer.JSQLPARSER_PRESENT).isFalse();
-		assertThat(NativeQueryEnhancer.select()).isEqualTo(NativeQueryEnhancer.REGEX);
-	}
-
-	void withSystemProperty(String property, @Nullable String value, Runnable exeution) {
-
-		String currentValue = System.getProperty(property);
-		if (value != null) {
-			System.setProperty(property, value);
-		} else {
-			System.clearProperty(property);
-		}
-		try {
-			exeution.run();
-		} finally {
-			if (currentValue != null) {
-				System.setProperty(property, currentValue);
-			} else {
-				System.clearProperty(property);
-			}
-		}
-
-	}
-
-
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerTckTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerTckTests.java
index 077d469177..98e19b6cb7 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerTckTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerTckTests.java
@@ -35,9 +35,8 @@ abstract class QueryEnhancerTckTests {
 	@MethodSource("nativeCountQueries") // GH-2773
 	void shouldDeriveNativeCountQuery(String query, String expected) {
 
-		DeclaredQuery declaredQuery = DeclaredQuery.of(query, true);
-		QueryEnhancer enhancer = createQueryEnhancer(declaredQuery);
-		String countQueryFor = enhancer.createCountQueryFor();
+		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery(query));
+		String countQueryFor = enhancer.createCountQueryFor(null);
 
 		// lenient cleanup to allow for rendering variance
 		String sanitized = countQueryFor.replaceAll("\r", " ").replaceAll("\n", " ").replaceAll(" {2}", " ")
@@ -120,8 +119,7 @@ static Stream<Arguments> nativeCountQueries() {
 	@MethodSource("jpqlCountQueries")
 	void shouldDeriveJpqlCountQuery(String query, String expected) {
 
-		DeclaredQuery declaredQuery = DeclaredQuery.of(query, false);
-		QueryEnhancer enhancer = createQueryEnhancer(declaredQuery);
+		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.jpqlQuery(query));
 		String countQueryFor = enhancer.createCountQueryFor(null);
 
 		assertThat(countQueryFor).isEqualToIgnoringCase(expected);
@@ -180,9 +178,8 @@ static Stream<Arguments> jpqlCountQueries() {
 	@MethodSource("nativeQueriesWithVariables")
 	void shouldDeriveNativeCountQueryWithVariable(String query, String expected) {
 
-		DeclaredQuery declaredQuery = DeclaredQuery.of(query, true);
-		QueryEnhancer enhancer = createQueryEnhancer(declaredQuery);
-		String countQueryFor = enhancer.createCountQueryFor();
+		QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery(query));
+		String countQueryFor = enhancer.createCountQueryFor(null);
 
 		assertThat(countQueryFor).isEqualToIgnoringCase(expected);
 	}
@@ -206,11 +203,11 @@ static Stream<Arguments> nativeQueriesWithVariables() {
 	// DATAJPA-1696
 	void findProjectionClauseWithIncludedFrom() {
 
-		StringQuery query = new StringQuery("select x, frommage, y from t", true);
+		DefaultEntityQuery query = new TestEntityQuery("select x, frommage, y from t", true);
 
 		assertThat(createQueryEnhancer(query).getProjection()).isEqualTo("x, frommage, y");
 	}
 
-	abstract QueryEnhancer createQueryEnhancer(DeclaredQuery declaredQuery);
+	abstract QueryEnhancer createQueryEnhancer(DeclaredQuery query);
 
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java
index 3113627c8e..da113f567b 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java
@@ -20,7 +20,6 @@
 
 import java.util.Arrays;
 import java.util.Collections;
-import java.util.List;
 import java.util.Set;
 import java.util.stream.Stream;
 
@@ -30,9 +29,12 @@
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
+
 import org.springframework.dao.InvalidDataAccessApiUsageException;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.jpa.domain.JpaSort;
+import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
+import org.springframework.data.repository.query.ReturnedType;
 
 /**
  * Unit tests for {@link QueryEnhancer}.
@@ -40,6 +42,7 @@
  * @author Diego Krupitza
  * @author Geoffrey Deremetz
  * @author Krzysztof Krason
+ * @author Mark Paluch
  */
 class QueryEnhancerUnitTests {
 
@@ -78,9 +81,9 @@ void allowsShortJpaSyntax() {
 
 	@ParameterizedTest
 	@MethodSource("detectsAliasWithUCorrectlySource")
-	void detectsAliasWithUCorrectly(DeclaredQuery query, String alias) {
+	void detectsAliasWithUCorrectly(DefaultEntityQuery query, String alias) {
 
-		assumeThat(query.getQueryString()).as("JsqlParser does not support simple JPA syntax.")
+		assumeThat(query.getQueryString()).as("JsqlParser does not support simple JPA syntax")
 				.doesNotStartWithIgnoringCase("from");
 
 		assertThat(getEnhancer(query).detectAlias()).isEqualTo(alias);
@@ -89,21 +92,21 @@ void detectsAliasWithUCorrectly(DeclaredQuery query, String alias) {
 	public static Stream<Arguments> detectsAliasWithUCorrectlySource() {
 
 		return Stream.of( //
-				Arguments.of(new StringQuery(QUERY, true), "u"), //
-				Arguments.of(new StringQuery(SIMPLE_QUERY, false), "u"), //
-				Arguments.of(new StringQuery(COUNT_QUERY, true), "u"), //
-				Arguments.of(new StringQuery(QUERY_WITH_AS, true), "u"), //
-				Arguments.of(new StringQuery("SELECT u FROM USER U", false), "U"), //
-				Arguments.of(new StringQuery("select u from  User u", true), "u"), //
-				Arguments.of(new StringQuery("select u from  com.acme.User u", true), "u"), //
-				Arguments.of(new StringQuery("select u from T05User u", true), "u") //
+				Arguments.of(new TestEntityQuery(QUERY, true), "u"), //
+				Arguments.of(new TestEntityQuery(SIMPLE_QUERY, false), "u"), //
+				Arguments.of(new TestEntityQuery(COUNT_QUERY, true), "u"), //
+				Arguments.of(new TestEntityQuery(QUERY_WITH_AS, true), "u"), //
+				Arguments.of(new TestEntityQuery("SELECT u FROM USER U", false), "U"), //
+				Arguments.of(new TestEntityQuery("select u from  User u", true), "u"), //
+				Arguments.of(new TestEntityQuery("select u from  com.acme.User u", true), "u"), //
+				Arguments.of(new TestEntityQuery("select u from T05User u", true), "u") //
 		);
 	}
 
 	@Test
 	void allowsFullyQualifiedEntityNamesInQuery() {
 
-		StringQuery query = new StringQuery(FQ_QUERY, true);
+		DefaultEntityQuery query = new TestEntityQuery(FQ_QUERY, true);
 
 		assertThat(getEnhancer(query).detectAlias()).isEqualTo("u");
 		assertCountQuery(FQ_QUERY, "select count(u) from org.acme.domain.User$Foo_Bar u", true);
@@ -112,20 +115,18 @@ void allowsFullyQualifiedEntityNamesInQuery() {
 	@Test // DATAJPA-252
 	void doesNotPrefixOrderReferenceIfOuterJoinAliasDetected() {
 
-		StringQuery query = new StringQuery("select p from Person p left join p.address address", true);
+		DefaultEntityQuery query = new TestEntityQuery("select p from Person p left join p.address address", true);
 
-		assertThat(getEnhancer(query).applySorting(Sort.by("address.city")))
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("address.city"))))
 				.endsWithIgnoringCase("order by address.city asc");
-		assertThat(getEnhancer(query).applySorting(Sort.by("address.city", "lastname"), "p"))
-				.endsWithIgnoringCase("order by address.city asc, p.lastname asc");
 	}
 
 	@Test // DATAJPA-252
 	void extendsExistingOrderByClausesCorrectly() {
 
-		StringQuery query = new StringQuery("select p from Person p order by p.lastname asc", true);
+		DefaultEntityQuery query = new TestEntityQuery("select p from Person p order by p.lastname asc", true);
 
-		assertThat(getEnhancer(query).applySorting(Sort.by("firstname"), "p"))
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("firstname"))))
 				.endsWithIgnoringCase("order by p.lastname asc, p.firstname asc");
 	}
 
@@ -134,9 +135,10 @@ void appliesIgnoreCaseOrderingCorrectly() {
 
 		Sort sort = Sort.by(Sort.Order.by("firstname").ignoreCase());
 
-		StringQuery query = new StringQuery("select p from Person p", true);
+		DefaultEntityQuery query = new TestEntityQuery("select p from Person p", true);
 
-		assertThat(getEnhancer(query).applySorting(sort, "p")).endsWithIgnoringCase("order by lower(p.firstname) asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
+				.endsWithIgnoringCase("order by lower(p.firstname) asc");
 	}
 
 	@Test // DATAJPA-296
@@ -144,9 +146,9 @@ void appendsIgnoreCaseOrderingCorrectly() {
 
 		Sort sort = Sort.by(Sort.Order.by("firstname").ignoreCase());
 
-		StringQuery query = new StringQuery("select p from Person p order by p.lastname asc", true);
+		DefaultEntityQuery query = new TestEntityQuery("select p from Person p order by p.lastname asc", true);
 
-		assertThat(getEnhancer(query).applySorting(sort, "p"))
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
 				.endsWithIgnoringCase("order by p.lastname asc, lower(p.firstname) asc");
 	}
 
@@ -160,12 +162,12 @@ void projectsCountQueriesForQueriesWithSubSelects() {
 	@Test // DATAJPA-148
 	void doesNotPrefixSortsIfFunction() {
 
-		StringQuery query = new StringQuery("select p from Person p", true);
+		DefaultEntityQuery query = new TestEntityQuery("select p from Person p", true);
 		Sort sort = Sort.by("sum(foo)");
 
 		QueryEnhancer enhancer = getEnhancer(query);
 
-		assertThatThrownBy(() -> enhancer.applySorting(sort, "p")) //
+		assertThatThrownBy(() -> enhancer.rewrite(getRewriteInformation(sort))) //
 				.isInstanceOf(InvalidDataAccessApiUsageException.class);
 	}
 
@@ -173,8 +175,8 @@ void doesNotPrefixSortsIfFunction() {
 	void findsExistingOrderByIndependentOfCase() {
 
 		Sort sort = Sort.by("lastname");
-		StringQuery originalQuery = new StringQuery("select p from Person p ORDER BY p.firstname", true);
-		String query = getEnhancer(originalQuery).applySorting(sort, "p");
+		DefaultEntityQuery originalQuery = new TestEntityQuery("select p from Person p ORDER BY p.firstname", true);
+		String query = getEnhancer(originalQuery).rewrite(getRewriteInformation(sort));
 
 		assertThat(query).endsWithIgnoringCase("ORDER BY p.firstname, p.lastname asc");
 	}
@@ -182,18 +184,17 @@ void findsExistingOrderByIndependentOfCase() {
 	@Test // GH-3263
 	void preserveSourceQueryWhenAddingSort() {
 
-		StringQuery query = new StringQuery("WITH all_projects AS (SELECT * FROM projects) SELECT * FROM all_projects p",
-				true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"WITH all_projects AS (SELECT * FROM projects) SELECT * FROM all_projects p", true);
 
-		assertThat(getEnhancer(query).applySorting(Sort.by("name"), "p")) //
-				.startsWithIgnoringCase(query.getQueryString())
-				.endsWithIgnoringCase("ORDER BY p.name ASC");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("name")))) //
+				.startsWithIgnoringCase(query.getQueryString()).endsWithIgnoringCase("ORDER BY p.name ASC");
 	}
 
 	@Test // GH-2812
 	void createCountQueryFromDeleteQuery() {
 
-		StringQuery query = new StringQuery("delete from some_table where id in :ids", true);
+		DefaultEntityQuery query = new TestEntityQuery("delete from some_table where id in :ids", true);
 
 		assertThat(getEnhancer(query).createCountQueryFor("p.lastname"))
 				.isEqualToIgnoringCase("delete from some_table where id in :ids");
@@ -202,7 +203,7 @@ void createCountQueryFromDeleteQuery() {
 	@Test // DATAJPA-456
 	void createCountQueryFromTheGivenCountProjection() {
 
-		StringQuery query = new StringQuery("select p.lastname,p.firstname from Person p", true);
+		DefaultEntityQuery query = new TestEntityQuery("select p.lastname,p.firstname from Person p", true);
 
 		assertThat(getEnhancer(query).createCountQueryFor("p.lastname"))
 				.isEqualToIgnoringCase("select count(p.lastname) from Person p");
@@ -211,24 +212,26 @@ void createCountQueryFromTheGivenCountProjection() {
 	@Test // DATAJPA-726
 	void detectsAliasesInPlainJoins() {
 
-		StringQuery query = new StringQuery("select p from Customer c join c.productOrder p where p.delay = true", true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"select p from Customer c join c.productOrder p where p.delay = true", true);
 		Sort sort = Sort.by("p.lineItems");
 
-		assertThat(getEnhancer(query).applySorting(sort, "c")).endsWithIgnoringCase("order by p.lineItems asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
+				.endsWithIgnoringCase("order by p.lineItems asc");
 	}
 
 	@Test // DATAJPA-736
 	void supportsNonAsciiCharactersInEntityNames() {
 
-		StringQuery query = new StringQuery("select u from Usèr u", true);
+		DefaultEntityQuery query = new TestEntityQuery("select u from Usèr u", true);
 
-		assertThat(getEnhancer(query).createCountQueryFor()).isEqualToIgnoringCase("select count(u) from Usèr u");
+		assertThat(getEnhancer(query).createCountQueryFor(null)).isEqualToIgnoringCase("select count(u) from Usèr u");
 	}
 
 	@Test // DATAJPA-798
 	void detectsAliasInQueryContainingLineBreaks() {
 
-		StringQuery query = new StringQuery("select \n u \n from \n User \nu", true);
+		DefaultEntityQuery query = new TestEntityQuery("select \n u \n from \n User \nu", true);
 
 		assertThat(getEnhancer(query).detectAlias()).isEqualTo("u");
 	}
@@ -237,26 +240,28 @@ void detectsAliasInQueryContainingLineBreaks() {
 	@Test // DATAJPA-815
 	void doesPrefixPropertyWithNonNative() {
 
-		StringQuery query = new StringQuery("from Cat c join Dog d", false);
+		DefaultEntityQuery query = new TestEntityQuery("from Cat c join Dog d", false);
 		Sort sort = Sort.by("dPropertyStartingWithJoinAlias");
 
-		assertThat(getEnhancer(query).applySorting(sort, "c")).endsWith("order by c.dPropertyStartingWithJoinAlias asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
+				.endsWith("order by c.dPropertyStartingWithJoinAlias asc");
 	}
 
 	@Test // DATAJPA-815
 	void doesPrefixPropertyWithNative() {
 
-		StringQuery query = new StringQuery("Select * from Cat c join Dog d", true);
+		DefaultEntityQuery query = new TestEntityQuery("Select * from Cat c join Dog d", true);
 		Sort sort = Sort.by("dPropertyStartingWithJoinAlias");
 
-		assertThat(getEnhancer(query).applySorting(sort, "c"))
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
 				.endsWithIgnoringCase("order by c.dPropertyStartingWithJoinAlias asc");
 	}
 
 	@Test // DATAJPA-938
 	void detectsConstructorExpressionInDistinctQuery() {
 
-		StringQuery query = new StringQuery("select distinct new com.example.Foo(b.name) from Bar b", false);
+		DefaultEntityQuery query = new TestEntityQuery("select distinct new com.example.Foo(b.name) from Bar b",
+				false);
 
 		assertThat(getEnhancer(query).hasConstructorExpression()).isTrue();
 	}
@@ -264,7 +269,7 @@ void detectsConstructorExpressionInDistinctQuery() {
 	@Test // DATAJPA-938
 	void detectsComplexConstructorExpression() {
 
-		StringQuery query = new StringQuery("select new foo.bar.Foo(ip.id, ip.name, sum(lp.amount)) " //
+		DefaultEntityQuery query = new TestEntityQuery("select new foo.bar.Foo(ip.id, ip.name, sum(lp.amount)) " //
 				+ "from Bar lp join lp.investmentProduct ip " //
 				+ "where (lp.toDate is null and lp.fromDate <= :now and lp.fromDate is not null) and lp.accountId = :accountId "
 				//
@@ -277,7 +282,7 @@ void detectsComplexConstructorExpression() {
 	@Test // DATAJPA-938
 	void detectsConstructorExpressionWithLineBreaks() {
 
-		StringQuery query = new StringQuery("select new foo.bar.FooBar(\na.id) from DtoA a ", false);
+		DefaultEntityQuery query = new TestEntityQuery("select new foo.bar.FooBar(\na.id) from DtoA a ", false);
 
 		assertThat(getEnhancer(query).hasConstructorExpression()).isTrue();
 	}
@@ -286,140 +291,138 @@ void detectsConstructorExpressionWithLineBreaks() {
 	@Test // DATAJPA-960
 	void doesNotQualifySortIfNoAliasDetectedNonNative() {
 
-		StringQuery query = new StringQuery("from mytable where ?1 is null", false);
+		DefaultEntityQuery query = new TestEntityQuery("from mytable where ?1 is null", false);
 
-		assertThat(getEnhancer(query).applySorting(Sort.by("firstname"))).endsWith("order by firstname asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("firstname"))))
+				.endsWith("order by firstname asc");
 	}
 
 	@Test // DATAJPA-960
 	void doesNotQualifySortIfNoAliasDetectedNative() {
 
-		StringQuery query = new StringQuery("Select * from mytable where ?1 is null", true);
+		DefaultEntityQuery query = new TestEntityQuery("Select * from mytable where ?1 is null", true);
 
-		assertThat(getEnhancer(query).applySorting(Sort.by("firstname"))).endsWithIgnoringCase("order by firstname asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("firstname"))))
+				.endsWithIgnoringCase("order by firstname asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotAllowWhitespaceInSort() {
 
-		StringQuery query = new StringQuery("select p from Person p", true);
+		DefaultEntityQuery query = new TestEntityQuery("select p from Person p", true);
 
 		Sort sort = Sort.by("case when foo then bar");
 
 		assertThatExceptionOfType(InvalidDataAccessApiUsageException.class)
-				.isThrownBy(() -> getEnhancer(query).applySorting(sort, "p"));
+				.isThrownBy(() -> getEnhancer(query).rewrite(getRewriteInformation(sort)));
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotPrefixUnsafeJpaSortFunctionCalls() {
 
 		JpaSort sort = JpaSort.unsafe("sum(foo)");
-		StringQuery query = new StringQuery("select p from Person p", true);
+		DefaultEntityQuery query = new TestEntityQuery("select p from Person p", true);
 
-		assertThat(getEnhancer(query).applySorting(sort, "p")).endsWithIgnoringCase("order by sum(foo) asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))).endsWithIgnoringCase("order by sum(foo) asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotPrefixMultipleAliasedFunctionCalls() {
 
-		StringQuery query = new StringQuery("SELECT AVG(m.price) AS avgPrice, SUM(m.stocks) AS sumStocks FROM Magazine m",
-				true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"SELECT AVG(m.price) AS avgPrice, SUM(m.stocks) AS sumStocks FROM Magazine m", true);
 		Sort sort = Sort.by("avgPrice", "sumStocks");
 
-		assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by avgPrice asc, sumStocks asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
+				.endsWithIgnoringCase("order by avgPrice asc, sumStocks asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotPrefixSingleAliasedFunctionCalls() {
 
-		StringQuery query = new StringQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true);
+		DefaultEntityQuery query = new TestEntityQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true);
 		Sort sort = Sort.by("avgPrice");
 
-		assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by avgPrice asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))).endsWithIgnoringCase("order by avgPrice asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void prefixesSingleNonAliasedFunctionCallRelatedSortProperty() {
 
-		StringQuery query = new StringQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true);
+		DefaultEntityQuery query = new TestEntityQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true);
 		Sort sort = Sort.by("someOtherProperty");
 
-		assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by m.someOtherProperty asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
+				.endsWithIgnoringCase("order by m.someOtherProperty asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void prefixesNonAliasedFunctionCallRelatedSortPropertyWhenSelectClauseContainsAliasedFunctionForDifferentProperty() {
 
-		StringQuery query = new StringQuery("SELECT m.name, AVG(m.price) AS avgPrice FROM Magazine m", true);
+		DefaultEntityQuery query = new TestEntityQuery("SELECT m.name, AVG(m.price) AS avgPrice FROM Magazine m",
+				true);
 		Sort sort = Sort.by("name", "avgPrice");
 
-		assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by m.name asc, avgPrice asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
+				.endsWithIgnoringCase("order by m.name asc, avgPrice asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotPrefixAliasedFunctionCallNameWithMultipleNumericParameters() {
 
-		StringQuery query = new StringQuery("SELECT SUBSTRING(m.name, 2, 5) AS trimmedName FROM Magazine m", true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"SELECT SUBSTRING(m.name, 2, 5) AS trimmedName FROM Magazine m", true);
 		Sort sort = Sort.by("trimmedName");
 
-		assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by trimmedName asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
+				.endsWithIgnoringCase("order by trimmedName asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotPrefixAliasedFunctionCallNameWithMultipleStringParameters() {
 
-		StringQuery query = new StringQuery("SELECT CONCAT(m.name, 'foo') AS extendedName FROM Magazine m", true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"SELECT CONCAT(m.name, 'foo') AS extendedName FROM Magazine m", true);
 		Sort sort = Sort.by("extendedName");
 
-		assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by extendedName asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort)))
+				.endsWithIgnoringCase("order by extendedName asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotPrefixAliasedFunctionCallNameWithUnderscores() {
 
-		StringQuery query = new StringQuery("SELECT AVG(m.price) AS avg_price FROM Magazine m", true);
+		DefaultEntityQuery query = new TestEntityQuery("SELECT AVG(m.price) AS avg_price FROM Magazine m", true);
 		Sort sort = Sort.by("avg_price");
 
-		assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by avg_price asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))).endsWithIgnoringCase("order by avg_price asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotPrefixAliasedFunctionCallNameWithDots() {
 
-		StringQuery query = new StringQuery("SELECT AVG(m.price) AS average FROM Magazine m", false);
+		DefaultEntityQuery query = new TestEntityQuery("SELECT AVG(m.price) AS average FROM Magazine m", false);
 		Sort sort = Sort.by("avg");
 
-		assertThat(getEnhancer(query).applySorting(sort, "m")).endsWith("order by m.avg asc");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))).endsWith("order by m.avg asc");
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotPrefixAliasedFunctionCallNameWithDotsNativeQuery() {
 
 		// this is invalid since the '.' character is not allowed. Not in sql nor in JPQL.
-		assertThatThrownBy(() -> new StringQuery("SELECT AVG(m.price) AS m.avg FROM Magazine m", true)) //
+		assertThatThrownBy(() -> new TestEntityQuery("SELECT AVG(m.price) AS m.avg FROM Magazine m", true)) //
 				.isInstanceOf(IllegalArgumentException.class);
 	}
 
 	@Test // DATAJPA-965, DATAJPA-970
 	void doesNotPrefixAliasedFunctionCallNameWhenQueryStringContainsMultipleWhiteSpaces() {
 
-		StringQuery query = new StringQuery("SELECT  AVG(  m.price  )   AS   avgPrice   FROM Magazine   m", true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"SELECT  AVG(  m.price  )   AS   avgPrice   FROM Magazine   m", true);
 		Sort sort = Sort.by("avgPrice");
 
-		assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by avgPrice asc");
-	}
-
-	@Test // DATAJPA-1000
-	void discoversCorrectAliasForJoinFetch() {
-
-		String queryString = "SELECT DISTINCT user FROM User user LEFT JOIN user.authorities AS authority";
-		Set<String> aliases = QueryUtils.getOuterJoinAliases(queryString);
-
-		StringQuery nativeQuery = new StringQuery(queryString, true);
-		Set<String> joinAliases = new JSqlParserQueryEnhancer(nativeQuery).getJoinAliases();
-
-		assertThat(aliases).containsExactly("authority");
-		assertThat(joinAliases).containsExactly("authority");
+		assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))).endsWithIgnoringCase("order by avgPrice asc");
 	}
 
 	@Test // DATAJPA-1171
@@ -433,17 +436,17 @@ void discoversAliasWithComplexFunction() {
 
 		assertThat(
 				QueryUtils.getFunctionAliases("select new MyDto(sum(case when myEntity.prop3=0 then 1 else 0 end) as myAlias")) //
-						.contains("myAlias");
+				.contains("myAlias");
 	}
 
 	@Test // DATAJPA-1506
 	void detectsAliasWithGroupAndOrderBy() {
 
-		StringQuery queryWithGroupNoAlias = new StringQuery("select * from User group by name", true);
-		StringQuery queryWithGroupAlias = new StringQuery("select * from User u group by name", true);
+		DefaultEntityQuery queryWithGroupNoAlias = new TestEntityQuery("select * from User group by name", true);
+		DefaultEntityQuery queryWithGroupAlias = new TestEntityQuery("select * from User u group by name", true);
 
-		StringQuery queryWithOrderNoAlias = new StringQuery("select * from User order by name", true);
-		StringQuery queryWithOrderAlias = new StringQuery("select * from User u order by name", true);
+		DefaultEntityQuery queryWithOrderNoAlias = new TestEntityQuery("select * from User order by name", true);
+		DefaultEntityQuery queryWithOrderAlias = new TestEntityQuery("select * from User u order by name", true);
 
 		assertThat(getEnhancer(queryWithGroupNoAlias).detectAlias()).isNull();
 		assertThat(getEnhancer(queryWithOrderNoAlias).detectAlias()).isNull();
@@ -454,12 +457,12 @@ void detectsAliasWithGroupAndOrderBy() {
 	@Test // DATAJPA-1061
 	void appliesSortCorrectlyForFieldAliases() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"SELECT  m.price, lower(m.title) AS title, a.name as authorName   FROM Magazine   m INNER JOIN m.author a",
 				true);
 		Sort sort = Sort.by("authorName");
 
-		String fullQuery = getEnhancer(query).applySorting(sort);
+		String fullQuery = getEnhancer(query).rewrite(getRewriteInformation(sort));
 
 		assertThat(fullQuery).endsWithIgnoringCase("order by authorName asc");
 	}
@@ -467,11 +470,11 @@ void appliesSortCorrectlyForFieldAliases() {
 	@Test // GH-2280
 	void appliesOrderingCorrectlyForFieldAliasWithIgnoreCase() {
 
-		StringQuery query = new StringQuery("SELECT customer.id as id, customer.name as name FROM CustomerEntity customer",
-				true);
+		DefaultEntityQuery query = new TestEntityQuery(
+				"SELECT customer.id as id, customer.name as name FROM CustomerEntity customer", true);
 		Sort sort = Sort.by(Sort.Order.by("name").ignoreCase());
 
-		String fullQuery = getEnhancer(query).applySorting(sort);
+		String fullQuery = getEnhancer(query).rewrite(getRewriteInformation(sort));
 
 		assertThat(fullQuery).isEqualToIgnoringCase(
 				"SELECT customer.id as id, customer.name as name FROM CustomerEntity customer order by lower(name) asc");
@@ -480,12 +483,12 @@ void appliesOrderingCorrectlyForFieldAliasWithIgnoreCase() {
 	@Test // DATAJPA-1061
 	void appliesSortCorrectlyForFunctionAliases() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"SELECT  m.price, lower(m.title) AS title, a.name as authorName   FROM Magazine   m INNER JOIN m.author a",
 				true);
 		Sort sort = Sort.by("title");
 
-		String fullQuery = getEnhancer(query).applySorting(sort);
+		String fullQuery = getEnhancer(query).rewrite(getRewriteInformation(sort));
 
 		assertThat(fullQuery).endsWithIgnoringCase("order by title asc");
 	}
@@ -493,12 +496,12 @@ void appliesSortCorrectlyForFunctionAliases() {
 	@Test // DATAJPA-1061
 	void appliesSortCorrectlyForSimpleField() {
 
-		StringQuery query = new StringQuery(
+		DefaultEntityQuery query = new TestEntityQuery(
 				"SELECT  m.price, lower(m.title) AS title, a.name as authorName   FROM Magazine   m INNER JOIN m.author a",
 				true);
 		Sort sort = Sort.by("price");
 
-		String fullQuery = getEnhancer(query).applySorting(sort);
+		String fullQuery = getEnhancer(query).rewrite(getRewriteInformation(sort));
 
 		assertThat(fullQuery).endsWithIgnoringCase("order by m.price asc");
 	}
@@ -506,30 +509,34 @@ void appliesSortCorrectlyForSimpleField() {
 	@Test
 	void createCountQuerySupportsLineBreakRightAfterDistinct() {
 
-		StringQuery query1 = new StringQuery("select\ndistinct\nuser.age,\n" + //
+		DefaultEntityQuery query1 = new TestEntityQuery("select\ndistinct\nuser.age,\n" + //
 				"user.name\n" + //
 				"from\nUser\nuser", true);
 
-		StringQuery query2 = new StringQuery("select\ndistinct user.age,\n" + //
+		DefaultEntityQuery query2 = new TestEntityQuery("select\ndistinct user.age,\n" + //
 				"user.name\n" + //
 				"from\nUser\nuser", true);
 
-		assertThat(getEnhancer(query1).createCountQueryFor()).isEqualTo(getEnhancer(query2).createCountQueryFor());
+		assertThat(getEnhancer(query1).createCountQueryFor(null)).isEqualTo(getEnhancer(query2).createCountQueryFor(null));
 	}
 
 	@Test
 	void detectsAliasWithGroupAndOrderByWithLineBreaks() {
 
-		StringQuery queryWithGroupAndLineBreak = new StringQuery("select * from User group\nby name", true);
-		StringQuery queryWithGroupAndLineBreakAndAlias = new StringQuery("select * from User u group\nby name", true);
+		DefaultEntityQuery queryWithGroupAndLineBreak = new TestEntityQuery("select * from User group\nby name",
+				true);
+		DefaultEntityQuery queryWithGroupAndLineBreakAndAlias = new TestEntityQuery(
+				"select * from User u group\nby name", true);
 
 		assertThat(getEnhancer(queryWithGroupAndLineBreak).detectAlias()).isNull();
 		assertThat(getEnhancer(queryWithGroupAndLineBreakAndAlias).detectAlias()).isEqualTo("u");
 
-		StringQuery queryWithOrderAndLineBreak = new StringQuery("select * from User order\nby name", true);
-		StringQuery queryWithOrderAndLineBreakAndAlias = new StringQuery("select * from User u order\nby name", true);
-		StringQuery queryWithOrderAndMultipleLineBreakAndAlias = new StringQuery("select * from User\nu\norder \n by name",
+		DefaultEntityQuery queryWithOrderAndLineBreak = new TestEntityQuery("select * from User order\nby name",
 				true);
+		DefaultEntityQuery queryWithOrderAndLineBreakAndAlias = new TestEntityQuery(
+				"select * from User u order\nby name", true);
+		DefaultEntityQuery queryWithOrderAndMultipleLineBreakAndAlias = new TestEntityQuery(
+				"select * from User\nu\norder \n by name", true);
 
 		assertThat(getEnhancer(queryWithOrderAndLineBreak).detectAlias()).isNull();
 		assertThat(getEnhancer(queryWithOrderAndLineBreakAndAlias).detectAlias()).isEqualTo("u");
@@ -538,7 +545,7 @@ void detectsAliasWithGroupAndOrderByWithLineBreaks() {
 
 	@ParameterizedTest // DATAJPA-1679
 	@MethodSource("findProjectionClauseWithDistinctSource")
-	void findProjectionClauseWithDistinct(DeclaredQuery query, String expected) {
+	void findProjectionClauseWithDistinct(DefaultEntityQuery query, String expected) {
 
 		SoftAssertions.assertSoftly(sofly -> sofly.assertThat(getEnhancer(query).getProjection()).isEqualTo(expected));
 	}
@@ -546,10 +553,10 @@ void findProjectionClauseWithDistinct(DeclaredQuery query, String expected) {
 	public static Stream<Arguments> findProjectionClauseWithDistinctSource() {
 
 		return Stream.of( //
-				Arguments.of(new StringQuery("select * from x", true), "*"), //
-				Arguments.of(new StringQuery("select a, b, c from x", true), "a, b, c"), //
-				Arguments.of(new StringQuery("select distinct a, b, c from x", true), "a, b, c"), //
-				Arguments.of(new StringQuery("select DISTINCT a, b, c from x", true), "a, b, c") //
+				Arguments.of(new TestEntityQuery("select * from x", true), "*"), //
+				Arguments.of(new TestEntityQuery("select a, b, c from x", true), "a, b, c"), //
+				Arguments.of(new TestEntityQuery("select distinct a, b, c from x", true), "a, b, c"), //
+				Arguments.of(new TestEntityQuery("select DISTINCT a, b, c from x", true), "a, b, c") //
 		);
 	}
 
@@ -567,33 +574,17 @@ void findProjectionClauseWithSubselectNative() {
 
 		// This is a required behavior the testcase in #findProjectionClauseWithSubselect tells why
 		String queryString = "select * from (select x from y)";
-		StringQuery query = new StringQuery(queryString, true);
+		DefaultEntityQuery query = new TestEntityQuery(queryString, true);
 
 		assertThat(getEnhancer(query).getProjection()).isEqualTo("*");
 	}
 
-	@Disabled
-	@ParameterizedTest // DATAJPA-252
-	@MethodSource("detectsJoinAliasesCorrectlySource")
-	void detectsJoinAliasesCorrectly(String queryString, List<String> aliases) {
-
-		StringQuery nativeQuery = new StringQuery(queryString, true);
-		StringQuery nonNativeQuery = new StringQuery(queryString, false);
-
-		Set<String> nativeJoinAliases = getEnhancer(nativeQuery).getJoinAliases();
-		Set<String> nonNativeJoinAliases = getEnhancer(nonNativeQuery).getJoinAliases();
-
-		assertThat(nonNativeJoinAliases).containsAll(nativeJoinAliases);
-		assertThat(nativeJoinAliases).hasSameSizeAs(aliases) //
-				.containsAll(aliases);
-	}
-
 	@Test // GH-2441
 	void correctFunctionAliasWithComplexNestedFunctions() {
 
 		String queryString = "\nSELECT \nCAST(('{' || string_agg(distinct array_to_string(c.institutes_ids, ','), ',') || '}') AS bigint[]) as institutesIds\nFROM\ncity c";
 
-		StringQuery nativeQuery = new StringQuery(queryString, true);
+		DefaultEntityQuery nativeQuery = new TestEntityQuery(queryString, true);
 		JSqlParserQueryEnhancer queryEnhancer = (JSqlParserQueryEnhancer) getEnhancer(nativeQuery);
 
 		assertThat(queryEnhancer.getSelectionAliases()).contains("institutesIds");
@@ -609,9 +600,10 @@ void correctApplySortOnComplexNestedFunctionQuery() {
 				+ "                                    city c\n" //
 				+ "                            ) dd";
 
-		StringQuery nativeQuery = new StringQuery(queryString, true);
+		DefaultEntityQuery nativeQuery = new TestEntityQuery(queryString, true);
 		QueryEnhancer queryEnhancer = getEnhancer(nativeQuery);
-		String result = queryEnhancer.applySorting(Sort.by(new Sort.Order(Sort.Direction.ASC, "institutesIds")));
+		String result = queryEnhancer
+				.rewrite(getRewriteInformation(Sort.by(new Sort.Order(Sort.Direction.ASC, "institutesIds"))));
 
 		assertThat(result).containsIgnoringCase("order by dd.institutesIds");
 	}
@@ -626,22 +618,22 @@ void modifyingQueriesAreDetectedCorrectly() {
 		boolean constructorExpressionNotConsideringQueryType = QueryUtils.hasConstructorExpression(modifyingQuery);
 		String countQueryForNotConsiderQueryType = QueryUtils.createCountQueryFor(modifyingQuery);
 
-		StringQuery modiQuery = new StringQuery(modifyingQuery, true);
+		DefaultEntityQuery modiQuery = new TestEntityQuery(modifyingQuery, true);
 
 		assertThat(modiQuery.getAlias()).isEqualToIgnoringCase(aliasNotConsideringQueryType);
 		assertThat(modiQuery.getProjection()).isEqualToIgnoringCase(projectionNotConsideringQueryType);
 		assertThat(modiQuery.hasConstructorExpression()).isEqualTo(constructorExpressionNotConsideringQueryType);
 
 		assertThat(countQueryForNotConsiderQueryType).isEqualToIgnoringCase(modifyingQuery);
-		assertThat(QueryEnhancerFactory.forQuery(modiQuery).createCountQueryFor()).isEqualToIgnoringCase(modifyingQuery);
+		assertThat(QueryEnhancer.create(modiQuery).createCountQueryFor(null)).isEqualToIgnoringCase(modifyingQuery);
 	}
 
 	@ParameterizedTest // GH-2593
 	@MethodSource("insertStatementIsProcessedSameAsDefaultSource")
 	void insertStatementIsProcessedSameAsDefault(String insertQuery) {
 
-		StringQuery stringQuery = new StringQuery(insertQuery, true);
-		QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery);
+		DefaultEntityQuery stringQuery = new TestEntityQuery(insertQuery, true);
+		QueryEnhancer queryEnhancer = QueryEnhancer.create(stringQuery);
 
 		Sort sorting = Sort.by("day").descending();
 
@@ -657,11 +649,11 @@ void insertStatementIsProcessedSameAsDefault(String insertQuery) {
 		assertThat(stringQuery.hasConstructorExpression()).isFalse();
 
 		// access over enhancer
-		assertThat(queryEnhancer.createCountQueryFor()).isEqualToIgnoringCase(queryUtilsCountQuery);
-		assertThat(queryEnhancer.applySorting(sorting)).isEqualTo(insertQuery); // cant check with queryutils result since
-																																						// query utils appens order by which is not
-																																						// supported by sql standard.
-		assertThat(queryEnhancer.getJoinAliases()).isEqualTo(queryUtilsOuterJoinAlias);
+		assertThat(queryEnhancer.createCountQueryFor(null)).isEqualToIgnoringCase(queryUtilsCountQuery);
+		assertThat(queryEnhancer.rewrite(getRewriteInformation(sorting))).isEqualTo(insertQuery); // cant check with
+																																															// queryutils result since
+		// query utils appens order by which is not
+		// supported by sql standard.
 		assertThat(queryEnhancer.detectAlias()).isEqualToIgnoringCase(queryUtilsDetectAlias);
 		assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase(queryUtilsProjection);
 		assertThat(queryEnhancer.hasConstructorExpression()).isFalse();
@@ -689,15 +681,20 @@ public static Stream<Arguments> detectsJoinAliasesCorrectlySource() {
 	}
 
 	private static void assertCountQuery(String originalQuery, String countQuery, boolean nativeQuery) {
-		assertCountQuery(new StringQuery(originalQuery, nativeQuery), countQuery);
+		assertCountQuery(new TestEntityQuery(originalQuery, nativeQuery), countQuery);
+	}
+
+	private static void assertCountQuery(DefaultEntityQuery originalQuery, String countQuery) {
+		assertThat(getEnhancer(originalQuery).createCountQueryFor(null)).isEqualToIgnoringCase(countQuery);
 	}
 
-	private static void assertCountQuery(StringQuery originalQuery, String countQuery) {
-		assertThat(getEnhancer(originalQuery).createCountQueryFor()).isEqualToIgnoringCase(countQuery);
+	private static DefaultQueryRewriteInformation getRewriteInformation(Sort sort) {
+		return new DefaultQueryRewriteInformation(sort,
+				ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()));
 	}
 
 	private static QueryEnhancer getEnhancer(DeclaredQuery query) {
-		return QueryEnhancerFactory.forQuery(query);
+		return QueryEnhancer.create(query);
 	}
 
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java
index 0b35d49b04..d4fb9a761d 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java
@@ -18,13 +18,12 @@
 import static org.assertj.core.api.Assertions.*;
 import static org.mockito.Mockito.*;
 
-import java.util.Collections;
-import java.util.List;
 import java.util.stream.Stream;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
+
 import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter;
 import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin;
 
@@ -48,12 +47,13 @@ void before() {
 		// we have one bindable parameter
 		when(parameters.getBindableParameters().iterator()).thenReturn(Stream.of(mock(JpaParameter.class)).iterator());
 
-		setterFactory = QueryParameterSetterFactory.basic(parameters);
+		setterFactory = QueryParameterSetterFactory.basic(parameters, true);
 	}
 
 	@Test // DATAJPA-1058
 	void noExceptionWhenQueryDoesNotContainNamedParameters() {
-		setterFactory.create(binding, DeclaredQuery.of("from Employee e", false));
+		setterFactory.create(binding,
+				EntityQuery.create(DeclaredQuery.jpqlQuery("from Employee e"), QueryEnhancerSelector.DEFAULT_SELECTOR));
 	}
 
 	@Test // DATAJPA-1058
@@ -63,41 +63,27 @@ void exceptionWhenQueryContainNamedParametersAndMethodParametersAreNotNamed() {
 
 		assertThatExceptionOfType(IllegalStateException.class) //
 				.isThrownBy(() -> setterFactory.create(binding,
-						DeclaredQuery.of("from Employee e where e.name = :NamedParameter", false))) //
+						EntityQuery.create(DeclaredQuery.jpqlQuery("from Employee e where e.name = :NamedParameter"),
+								QueryEnhancerSelector.DEFAULT_SELECTOR))) //
 				.withMessageContaining("Java 8") //
 				.withMessageContaining("@Param") //
 				.withMessageContaining("-parameters");
 	}
 
-	@Test // DATAJPA-1281
-	void exceptionWhenCriteriaQueryContainsInsufficientAmountOfParameters() {
-
-		// no parameter present in the criteria query
-		List<ParameterMetadataProvider.ParameterMetadata<?>> metadata = Collections.emptyList();
-		QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.forCriteriaQuery(parameters, metadata);
-
-		// one argument present in the method signature
-		when(binding.getRequiredPosition()).thenReturn(1);
-		when(binding.getOrigin()).thenReturn(ParameterOrigin.ofParameter(null, 1));
-
-		assertThatExceptionOfType(IllegalArgumentException.class) //
-				.isThrownBy(() -> setterFactory.create(binding,
-						DeclaredQuery.of("from Employee e where e.name = :NamedParameter", false))) //
-				.withMessage("At least 1 parameter(s) provided but only 0 parameter(s) present in query");
-	}
-
 	@Test // DATAJPA-1281
 	void exceptionWhenBasicQueryContainsInsufficientAmountOfParameters() {
 
 		// no parameter present in the criteria query
-		QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters);
+		QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters, false);
 
 		// one argument present in the method signature
 		when(binding.getRequiredPosition()).thenReturn(1);
 		when(binding.getOrigin()).thenReturn(ParameterOrigin.ofParameter(null, 1));
 
 		assertThatExceptionOfType(IllegalArgumentException.class) //
-				.isThrownBy(() -> setterFactory.create(binding, DeclaredQuery.of("from Employee e where e.name = ?1", false))) //
+				.isThrownBy(() -> setterFactory.create(binding,
+						EntityQuery.create(DeclaredQuery.jpqlQuery("from Employee e where e.name = ?1"),
+								QueryEnhancerSelector.DEFAULT_SELECTOR))) //
 				.withMessage("At least 1 parameter(s) provided but only 0 parameter(s) present in query");
 	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java
index 1d4f917a5d..a7aecc36a7 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java
@@ -32,7 +32,9 @@
 import jakarta.persistence.criteria.From;
 import jakarta.persistence.criteria.Join;
 import jakarta.persistence.criteria.JoinType;
+import jakarta.persistence.criteria.Nulls;
 import jakarta.persistence.criteria.Path;
+import jakarta.persistence.criteria.Nulls;
 import jakarta.persistence.criteria.Root;
 import jakarta.persistence.spi.PersistenceProvider;
 import jakarta.persistence.spi.PersistenceProviderResolver;
@@ -127,7 +129,6 @@ void prefersFetchOverJoin() {
 
 		assertThat(expr.getParentPath()).hasFieldOrPropertyWithValue("fetched", true);
 		assertThat(from.getFetches()).hasSize(1);
-		assertThat(from.getJoins()).hasSize(1);
 	}
 
 	@Test // DATAJPA-401, DATAJPA-1238
@@ -353,8 +354,8 @@ void toOrdersCanSortByJoinColumn() {
 		assertThat(orders).hasSize(1);
 	}
 
-	@Test // GH-3529
-	void nullPrecedenceThroughCriteriaApiNotYetSupported() {
+	@Test // GH-3529, GH-3587
+	void queryUtilsConsidersNullPrecedence() {
 
 		CriteriaBuilder builder = em.getCriteriaBuilder();
 		CriteriaQuery<User> query = builder.createQuery(User.class);
@@ -363,8 +364,10 @@ void nullPrecedenceThroughCriteriaApiNotYetSupported() {
 
 		Sort sort = Sort.by(Sort.Order.desc("manager").nullsFirst());
 
-		assertThatExceptionOfType(UnsupportedOperationException.class)
-				.isThrownBy(() -> QueryUtils.toOrders(sort, join, builder));
+		List<jakarta.persistence.criteria.Order> orders = QueryUtils.toOrders(sort, join, builder);
+		for (jakarta.persistence.criteria.Order order : orders) {
+			assertThat(order.getNullPrecedence()).isEqualTo(Nulls.FIRST);
+		}
 	}
 
 	/**
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeIntegrationTests.java
index 9f7e2da8ea..5cf31423a9 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeIntegrationTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeIntegrationTests.java
@@ -24,6 +24,7 @@
 
 import javax.sql.DataSource;
 
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -38,7 +39,6 @@
 import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
 import org.springframework.data.repository.query.Param;
 import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
-import org.springframework.lang.Nullable;
 import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean;
 import org.springframework.orm.jpa.JpaTransactionManager;
 import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/SimpleJpaQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/SimpleJpaQueryUnitTests.java
index 5d2beb3d9b..188166d3bd 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/SimpleJpaQueryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/SimpleJpaQueryUnitTests.java
@@ -29,6 +29,7 @@
 import java.util.List;
 import java.util.Optional;
 
+import org.jspecify.annotations.Nullable;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
@@ -46,7 +47,6 @@
 import org.springframework.data.jpa.provider.QueryExtractor;
 import org.springframework.data.jpa.repository.NativeQuery;
 import org.springframework.data.jpa.repository.Query;
-import org.springframework.data.jpa.repository.QueryRewriter;
 import org.springframework.data.jpa.repository.sample.UserRepository;
 import org.springframework.data.projection.ProjectionFactory;
 import org.springframework.data.projection.SpelAwareProxyProjectionFactory;
@@ -55,7 +55,6 @@
 import org.springframework.data.repository.query.RepositoryQuery;
 import org.springframework.data.repository.query.ValueExpressionDelegate;
 import org.springframework.data.util.TypeInformation;
-import org.springframework.lang.Nullable;
 
 /**
  * Unit test for {@link SimpleJpaQuery}.
@@ -75,6 +74,9 @@
 @MockitoSettings(strictness = Strictness.LENIENT)
 class SimpleJpaQueryUnitTests {
 
+	private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(),
+			QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT);
+
 	private static final String USER_QUERY = "select u from User u";
 
 	private JpaQueryMethod method;
@@ -119,8 +121,8 @@ void prefersDeclaredCountQueryOverCreatingOne() throws Exception {
 				extractor);
 		when(em.createQuery("foo", Long.class)).thenReturn(typedQuery);
 
-		SimpleJpaQuery jpaQuery = new SimpleJpaQuery(method, em, "select u from User u", null,
-				QueryRewriter.IdentityQueryRewriter.INSTANCE, ValueExpressionDelegate.create());
+		SimpleJpaQuery jpaQuery = new SimpleJpaQuery(method, em, method.getDeclaredQuery("select u from User u"), null,
+				CONFIG);
 
 		assertThat(jpaQuery.createCountQuery(new JpaParametersParameterAccessor(method.getParameters(), new Object[] {})))
 				.isEqualTo(typedQuery);
@@ -134,8 +136,8 @@ void doesNotApplyPaginationToCountQuery() throws Exception {
 		Method method = UserRepository.class.getMethod("findAllPaged", Pageable.class);
 		JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor);
 
-		AbstractJpaQuery jpaQuery = new SimpleJpaQuery(queryMethod, em, "select u from User u", null,
-				QueryRewriter.IdentityQueryRewriter.INSTANCE, ValueExpressionDelegate.create());
+		AbstractJpaQuery jpaQuery = new SimpleJpaQuery(queryMethod, em,
+				queryMethod.getDeclaredQuery("select u from User u"), null, CONFIG);
 		jpaQuery.createCountQuery(
 				new JpaParametersParameterAccessor(queryMethod.getParameters(), new Object[] { PageRequest.of(1, 10) }));
 
@@ -149,9 +151,8 @@ void discoversNativeQuery() throws Exception {
 
 		Method method = SampleRepository.class.getMethod("findNativeByLastname", String.class);
 		JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor);
-		AbstractJpaQuery jpaQuery = JpaQueryFactory.INSTANCE.fromMethodWithQueryString(queryMethod, em,
-				queryMethod.getAnnotatedQuery(), null, QueryRewriter.IdentityQueryRewriter.INSTANCE,
-				ValueExpressionDelegate.create());
+		AbstractJpaQuery jpaQuery = JpaQueryLookupStrategy.DeclaredQueryLookupStrategy.createStringQuery(queryMethod, em,
+				queryMethod.getRequiredDeclaredQuery(), null, CONFIG);
 
 		assertThat(jpaQuery).isInstanceOf(NativeJpaQuery.class);
 
@@ -169,9 +170,8 @@ void discoversNativeQueryFromNativeQueryInterface() throws Exception {
 
 		Method method = SampleRepository.class.getMethod("findByLastnameNativeAnnotation", String.class);
 		JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor);
-		AbstractJpaQuery jpaQuery = JpaQueryFactory.INSTANCE.fromMethodWithQueryString(queryMethod, em,
-				queryMethod.getAnnotatedQuery(), null, QueryRewriter.IdentityQueryRewriter.INSTANCE,
-				ValueExpressionDelegate.create());
+		AbstractJpaQuery jpaQuery = JpaQueryLookupStrategy.DeclaredQueryLookupStrategy.createStringQuery(queryMethod, em,
+				queryMethod.getRequiredDeclaredQuery(), null, CONFIG);
 
 		assertThat(jpaQuery).isInstanceOf(NativeJpaQuery.class);
 
@@ -239,10 +239,11 @@ void allowsCountQueryUsingParametersNotInOriginalQuery() throws Exception {
 		when(em.createNativeQuery(anyString())).thenReturn(query);
 
 		AbstractJpaQuery jpaQuery = createJpaQuery(
-				SampleRepository.class.getMethod("findAllWithBindingsOnlyInCountQuery", String.class, Pageable.class), Optional.empty());
+				SampleRepository.class.getMethod("findAllWithBindingsOnlyInCountQuery", String.class, Pageable.class),
+				Optional.empty());
 
 		jpaQuery.doCreateCountQuery(new JpaParametersParameterAccessor(jpaQuery.getQueryMethod().getParameters(),
-				new Object[]{"data", PageRequest.of(0, 10)}));
+				new Object[] { "data", PageRequest.of(0, 10) }));
 
 		ArgumentCaptor<String> queryStringCaptor = ArgumentCaptor.forClass(String.class);
 		verify(em).createQuery(queryStringCaptor.capture(), eq(Long.class));
@@ -282,9 +283,9 @@ void resolvesExpressionInCountQuery() throws Exception {
 		Method method = SampleRepository.class.getMethod("findAllWithExpressionInCountQuery", Pageable.class);
 		JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor);
 
-		AbstractJpaQuery jpaQuery = new SimpleJpaQuery(queryMethod, em, "select u from User u",
-				"select count(u.id) from #{#entityName} u", QueryRewriter.IdentityQueryRewriter.INSTANCE,
-				ValueExpressionDelegate.create());
+		AbstractJpaQuery jpaQuery = new SimpleJpaQuery(queryMethod, em,
+				queryMethod.getDeclaredQuery("select u from User u"),
+				queryMethod.getDeclaredQuery("select count(u.id) from #{#entityName} u"), CONFIG);
 		jpaQuery.createCountQuery(
 				new JpaParametersParameterAccessor(queryMethod.getParameters(), new Object[] { PageRequest.of(1, 10) }));
 
@@ -296,16 +297,18 @@ private AbstractJpaQuery createJpaQuery(Method method) {
 		return createJpaQuery(method, null);
 	}
 
-	private AbstractJpaQuery createJpaQuery(JpaQueryMethod queryMethod, @Nullable String queryString, @Nullable String countQueryString) {
+	private AbstractJpaQuery createJpaQuery(JpaQueryMethod queryMethod, @Nullable DeclaredQuery query,
+			@Nullable DeclaredQuery countQzery) {
 
-		return JpaQueryFactory.INSTANCE.fromMethodWithQueryString(queryMethod, em, queryString, countQueryString,
-				QueryRewriter.IdentityQueryRewriter.INSTANCE, ValueExpressionDelegate.create());
+		return JpaQueryLookupStrategy.DeclaredQueryLookupStrategy.createStringQuery(queryMethod, em, query, countQzery,
+				CONFIG);
 	}
 
-	private AbstractJpaQuery createJpaQuery(Method method, @Nullable Optional<String> countQueryString) {
+	private AbstractJpaQuery createJpaQuery(Method method, @Nullable Optional<DeclaredQuery> countQueryString) {
 
 		JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor);
-		return createJpaQuery(queryMethod, queryMethod.getAnnotatedQuery(), countQueryString == null ? null : countQueryString.orElse(queryMethod.getCountQuery()));
+		return createJpaQuery(queryMethod, queryMethod.getRequiredDeclaredQuery(),
+				countQueryString == null ? null : countQueryString.orElse(queryMethod.getDeclaredCountQuery()));
 	}
 
 	interface SampleRepository {
@@ -337,8 +340,8 @@ interface SampleRepository {
 		@Query(value = "select u from #{#entityName} u", countQuery = "select count(u.id) from #{#entityName} u")
 		List<User> findAllWithExpressionInCountQuery(Pageable pageable);
 
-
-		@Query(value = "select u from User u", countQuery = "select count(u.id) from #{#entityName} u where u.name = :#{#arg0}")
+		@Query(value = "select u from User u",
+				countQuery = "select count(u.id) from #{#entityName} u where u.name = :#{#arg0}")
 		List<User> findAllWithBindingsOnlyInCountQuery(String arg0, Pageable pageable);
 
 		// Typo in named parameter
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StubJpaParameterParameterAccessor.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StubJpaParameterParameterAccessor.java
new file mode 100644
index 0000000000..e25cb03b58
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StubJpaParameterParameterAccessor.java
@@ -0,0 +1,93 @@
+/*
+ * 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.springframework.data.jpa.repository.query;
+
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import org.mockito.Mockito;
+import org.springframework.core.MethodParameter;
+import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter;
+import org.springframework.data.util.TypeInformation;
+
+/**
+ * @author Christoph Strobl
+ */
+public class StubJpaParameterParameterAccessor extends JpaParametersParameterAccessor {
+
+	private StubJpaParameterParameterAccessor(JpaParameters parameters, Object[] values) {
+		super(parameters, values);
+	}
+
+	static JpaParametersParameterAccessor accessor(Object... values) {
+
+		Class<?>[] parameterTypes = Arrays.stream(values).map(it -> it != null ? it.getClass() : Object.class)
+				.toArray(Class<?>[]::new);
+		return accessor(parameterTypes, values);
+	}
+
+	static JpaParametersParameterAccessor accessor(Class<?>... parameterTypes) {
+		return accessor(parameterTypes, new Object[parameterTypes.length]);
+	}
+
+	static AccessorBuilder accessorFor(Class<?>... parameterTypes) {
+		return arguments -> accessor(parameterTypes, arguments);
+
+	}
+
+	interface AccessorBuilder {
+		JpaParametersParameterAccessor withValues(Object... arguments);
+	}
+
+	@SuppressWarnings({ "rawtypes", "unchecked" })
+	static JpaParametersParameterAccessor accessor(Class<?>[] parameterTypes, Object... parameters) {
+
+		List<JpaParameter> parametersList = new ArrayList<>(parameterTypes.length);
+		List<Object> valueList = new ArrayList<>(parameterTypes.length);
+
+		for (int i = 0; i < parameterTypes.length; i++) {
+
+			if (i < parameters.length) {
+				valueList.add(parameters[i]);
+			}
+
+			Class<?> parameterType = parameterTypes[i];
+			MethodParameter mock = Mockito.mock(MethodParameter.class);
+			when(mock.getParameterType()).thenReturn((Class) parameterType);
+			JpaParameter parameter = new JpaParameter(mock, TypeInformation.of(parameterType));
+			parametersList.add(parameter);
+		}
+
+		return new StubJpaParameterParameterAccessor(new JpaParameters(parametersList), valueList.toArray());
+	}
+
+	@Override
+	public String toString() {
+		List<String> parameters = new ArrayList<>(getParameters().getNumberOfParameters());
+
+		for (int i = 0; i < getParameters().getNumberOfParameters(); i++) {
+			Object value = getValue(i);
+			if (value == null) {
+				value = "null";
+			}
+			parameters.add("%s: %s (%s)".formatted(i, value, getParameters().getParameter(i).getType().getSimpleName()));
+		}
+		return "%s".formatted(parameters);
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TemplatedQueryUnitTests.java
similarity index 78%
rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQueryUnitTests.java
rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TemplatedQueryUnitTests.java
index 2b81871822..6581b628f7 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQueryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TemplatedQueryUnitTests.java
@@ -28,12 +28,12 @@
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
 
-import org.springframework.data.expression.ValueExpressionParser;
 import org.springframework.data.jpa.repository.query.ParameterBinding.LikeParameterBinding;
+import org.springframework.data.repository.query.ValueExpressionDelegate;
 import org.springframework.data.repository.query.parser.Part.Type;
 
 /**
- * Unit tests for {@link ExpressionBasedStringQuery}.
+ * Unit tests for {@link TemplatedQuery}.
  *
  * @author Thomas Darimont
  * @author Oliver Gierke
@@ -45,9 +45,11 @@
  */
 @ExtendWith(MockitoExtension.class)
 @MockitoSettings(strictness = Strictness.LENIENT)
-class ExpressionBasedStringQueryUnitTests {
+class TemplatedQueryUnitTests {
+
+	private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(),
+			QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT);
 
-	private static final ValueExpressionParser PARSER = ValueExpressionParser.create();
 	@Mock JpaEntityMetadata<?> metadata;
 
 	@BeforeEach
@@ -59,14 +61,14 @@ void setUp() {
 	void shouldReturnQueryWithDomainTypeExpressionReplacedWithSimpleDomainTypeName() {
 
 		String source = "select u from #{#entityName} u where u.firstname like :firstname";
-		StringQuery query = new ExpressionBasedStringQuery(source, metadata, PARSER, false);
+		EntityQuery query = jpqlEntityQuery(source);
 		assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like :firstname");
 	}
 
 	@Test // DATAJPA-424
 	void renderAliasInExpressionQueryCorrectly() {
 
-		StringQuery query = new ExpressionBasedStringQuery("select u from #{#entityName} u", metadata, PARSER, true);
+		DefaultEntityQuery query = jpqlEntityQuery("select u from #{#entityName} u");
 		assertThat(query.getAlias()).isEqualTo("u");
 		assertThat(query.getQueryString()).isEqualTo("select u from User u");
 	}
@@ -74,12 +76,11 @@ void renderAliasInExpressionQueryCorrectly() {
 	@Test // DATAJPA-1695
 	void shouldDetectBindParameterCountCorrectly() {
 
-		StringQuery query = new ExpressionBasedStringQuery(
+		EntityQuery query = jpqlEntityQuery(
 				"select n from #{#entityName} n where (LOWER(n.name) LIKE LOWER(:#{#networkRequest.name})) OR :#{#networkRequest.name} IS NULL "
 						+ "AND (LOWER(n.server) LIKE LOWER(:#{#networkRequest.server})) OR :#{#networkRequest.server} IS NULL "
 						+ "AND (n.createdAt >= :#{#networkRequest.createdTime.startDateTime}) AND (n.createdAt <=:#{#networkRequest.createdTime.endDateTime}) "
-						+ "AND (n.updatedAt >= :#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=:#{#networkRequest.updatedTime.endDateTime})",
-				metadata, PARSER, false);
+						+ "AND (n.updatedAt >= :#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=:#{#networkRequest.updatedTime.endDateTime})");
 
 		assertThat(query.getParameterBindings()).hasSize(8);
 	}
@@ -87,12 +88,11 @@ void shouldDetectBindParameterCountCorrectly() {
 	@Test // GH-2228
 	void shouldDetectBindParameterCountCorrectlyWithJDBCStyleParameters() {
 
-		StringQuery query = new ExpressionBasedStringQuery(
+		EntityQuery query = jpqlEntityQuery(
 				"select n from #{#entityName} n where (LOWER(n.name) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.name},'%')), '')) OR ?#{#networkRequest.name} IS NULL )"
 						+ "AND (LOWER(n.server) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.server},'%')), '')) OR ?#{#networkRequest.server} IS NULL)"
 						+ "AND (n.createdAt >= ?#{#networkRequest.createdTime.startDateTime}) AND (n.createdAt <=?#{#networkRequest.createdTime.endDateTime})"
-						+ "AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})",
-				metadata, PARSER, false);
+						+ "AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})");
 
 		assertThat(query.getParameterBindings()).hasSize(8);
 	}
@@ -100,38 +100,28 @@ void shouldDetectBindParameterCountCorrectlyWithJDBCStyleParameters() {
 	@Test
 	void shouldDetectComplexNativeQueriesWithSpelAsNonNative() {
 
-		StringQuery query = new ExpressionBasedStringQuery(
+		DefaultEntityQuery query = jpqlEntityQuery(
 				"select n from #{#entityName} n where (LOWER(n.name) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.name},'%')), '')) OR ?#{#networkRequest.name} IS NULL )"
 						+ "AND (LOWER(n.server) LIKE LOWER(NULLIF(text(concat('%',?#{#networkRequest.server},'%')), '')) OR ?#{#networkRequest.server} IS NULL)"
 						+ "AND (n.createdAt >= ?#{#networkRequest.createdTime.startDateTime}) AND (n.createdAt <=?#{#networkRequest.createdTime.endDateTime})"
-						+ "AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})",
-				metadata, PARSER, true);
-
-		assertThat(query.isNativeQuery()).isFalse();
-	}
-
-	@Test
-	void shouldDetectSimpleNativeQueriesWithSpelAsNonNative() {
+						+ "AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})");
 
-		StringQuery query = new ExpressionBasedStringQuery("select n from #{#entityName} n", metadata, PARSER, true);
-
-		assertThat(query.isNativeQuery()).isFalse();
+		assertThat(query.isNative()).isFalse();
 	}
 
 	@Test
 	void shouldDetectSimpleNativeQueriesWithoutSpelAsNative() {
 
-		StringQuery query = new ExpressionBasedStringQuery("select u from User u", metadata, PARSER, true);
+		DefaultEntityQuery query = nativeEntityQuery("select u from User u");
 
-		assertThat(query.isNativeQuery()).isTrue();
+		assertThat(query.isNative()).isTrue();
 	}
 
 	@Test // GH-3041
 	void namedExpressionsShouldCreateLikeBindings() {
 
-		StringQuery query = new ExpressionBasedStringQuery(
-				"select u from User u where u.firstname like %:#{foo} or u.firstname like :#{foo}%", metadata, PARSER,
-				false);
+		EntityQuery query = jpqlEntityQuery(
+				"select u from User u where u.firstname like %:#{foo} or u.firstname like :#{foo}%");
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString()).isEqualTo(
@@ -154,9 +144,8 @@ void namedExpressionsShouldCreateLikeBindings() {
 	@Test // GH-3041
 	void indexedExpressionsShouldCreateLikeBindings() {
 
-		StringQuery query = new ExpressionBasedStringQuery(
-				"select u from User u where u.firstname like %?#{foo} or u.firstname like ?#{foo}%", metadata, PARSER,
-				false);
+		EntityQuery query = jpqlEntityQuery(
+				"select u from User u where u.firstname like %?#{foo} or u.firstname like ?#{foo}%");
 
 		assertThat(query.hasParameterBindings()).isTrue();
 		assertThat(query.getQueryString())
@@ -179,8 +168,7 @@ void indexedExpressionsShouldCreateLikeBindings() {
 	@Test
 	void doesTemplatingWhenEntityNameSpelIsPresent() {
 
-		StringQuery query = new ExpressionBasedStringQuery("select #{#entityName + 'Hallo'} from #{#entityName} u",
-				metadata, PARSER, false);
+		EntityQuery query = jpqlEntityQuery("select #{#entityName + 'Hallo'} from #{#entityName} u");
 
 		assertThat(query.getQueryString()).isEqualTo("select UserHallo from User u");
 	}
@@ -188,8 +176,7 @@ void doesTemplatingWhenEntityNameSpelIsPresent() {
 	@Test
 	void doesNoTemplatingWhenEntityNameSpelIsNotPresent() {
 
-		StringQuery query = new ExpressionBasedStringQuery("select #{#entityName + 'Hallo'} from User u", metadata,
-				PARSER, false);
+		EntityQuery query = jpqlEntityQuery("select #{#entityName + 'Hallo'} from User u");
 
 		assertThat(query.getQueryString()).isEqualTo("select UserHallo from User u");
 	}
@@ -197,9 +184,16 @@ void doesNoTemplatingWhenEntityNameSpelIsNotPresent() {
 	@Test
 	void doesTemplatingWhenEntityNameSpelIsPresentForBindParameter() {
 
-		StringQuery query = new ExpressionBasedStringQuery("select u from #{#entityName} u where name = :#{#something}",
-				metadata, PARSER, false);
+		EntityQuery query = jpqlEntityQuery("select u from #{#entityName} u where name = :#{#something}");
 
 		assertThat(query.getQueryString()).isEqualTo("select u from User u where name = :__$synthetic$__1");
 	}
+
+	private DefaultEntityQuery nativeEntityQuery(String source) {
+		return (DefaultEntityQuery) TemplatedQuery.create(DeclaredQuery.nativeQuery(source), metadata, CONFIG);
+	}
+
+	private DefaultEntityQuery jpqlEntityQuery(String source) {
+		return (DefaultEntityQuery) TemplatedQuery.create(DeclaredQuery.jpqlQuery(source), metadata, CONFIG);
+	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TestEntityQuery.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TestEntityQuery.java
new file mode 100644
index 0000000000..25c0848908
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TestEntityQuery.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.query;
+
+/**
+ * Test-variant of {@link DefaultEntityQuery} with a simpler constructor.
+ *
+ * @author Mark Paluch
+ */
+class TestEntityQuery extends DefaultEntityQuery {
+
+	/**
+	 * Creates a new {@link DefaultEntityQuery} from the given JPQL query.
+	 *
+	 * @param query must not be {@literal null} or empty.
+	 */
+	TestEntityQuery(String query, boolean isNative) {
+
+		super(PreprocessedQuery.parse(isNative ? DeclaredQuery.nativeQuery(query) : DeclaredQuery.jpqlQuery(query)),
+				QueryEnhancerSelector.DEFAULT_SELECTOR
+						.select(isNative ? DeclaredQuery.nativeQuery(query) : DeclaredQuery.jpqlQuery(query)));
+	}
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java
index 13ee35f497..b64c4de2f4 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java
@@ -16,7 +16,7 @@
 package org.springframework.data.jpa.repository.sample;
 
 // DATAJPA-1334
-class NameOnlyDto {
+public class NameOnlyDto {
 
 	private String firstname;
 	private String lastname;
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RepositoryMethodsWithEntityGraphConfigRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RepositoryMethodsWithEntityGraphConfigRepository.java
index fe8e0dd4b6..b02a606673 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RepositoryMethodsWithEntityGraphConfigRepository.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RepositoryMethodsWithEntityGraphConfigRepository.java
@@ -27,9 +27,9 @@
 import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
 import org.springframework.data.querydsl.QuerydslPredicateExecutor;
 import org.springframework.data.repository.CrudRepository;
-import org.springframework.lang.Nullable;
 
 import com.querydsl.core.types.Predicate;
+import org.jspecify.annotations.Nullable;
 
 /**
  * Custom repository interface that customizes the fetching behavior of querys of well known repository interface
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java
index c4ebaf9f43..efe9d564a2 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java
@@ -28,6 +28,8 @@
 import java.util.Set;
 import java.util.stream.Stream;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.domain.Limit;
 import org.springframework.data.domain.OffsetScrollPosition;
 import org.springframework.data.domain.Page;
@@ -37,6 +39,7 @@
 import org.springframework.data.domain.Slice;
 import org.springframework.data.domain.Sort;
 import org.springframework.data.domain.Window;
+import org.springframework.data.jpa.domain.sample.Address;
 import org.springframework.data.jpa.domain.sample.Role;
 import org.springframework.data.jpa.domain.sample.SpecialUser;
 import org.springframework.data.jpa.domain.sample.User;
@@ -50,9 +53,10 @@
 import org.springframework.data.querydsl.ListQuerydslPredicateExecutor;
 import org.springframework.data.repository.CrudRepository;
 import org.springframework.data.repository.query.Param;
-import org.springframework.lang.Nullable;
 import org.springframework.transaction.annotation.Transactional;
 
+import com.querydsl.core.types.Predicate;
+
 /**
  * Repository interface for {@code User}s.
  *
@@ -298,13 +302,6 @@ Window<User> findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(S
 	// DATAJPA-460
 	List<User> deleteByLastname(String lastname);
 
-	/**
-	 * @see <a href="https://issues.apache.org/jira/browse/OPENJPA-2484">OPENJPA-2484</a>
-	 */
-	// DATAJPA-505
-	// @Query(value = "select u.binaryData from User u where u.id = :id")
-	// byte[] findBinaryDataByIdJpaQl(@Param("id") Integer id);
-
 	/**
 	 * Explicitly mapped to a procedure with name "plus1inout" in database.
 	 */
@@ -546,7 +543,7 @@ List<User> findUsersByFirstnameForSpELExpressionWithParameterIndexOnlyWithEntity
 
 	List<RolesAndFirstname> findRolesAndFirstnameBy();
 
-	@Query(value = "FROM User u")
+	@Query(value = "SELECT u FROM User u")
 	List<IdOnly> findIdOnly();
 
 	// DATAJPA-1172
@@ -642,13 +639,13 @@ Page<User> findAllOrderedBySpecialNameMultipleParams(@Param("name") String name,
 	List<NameOnly> findAllInterfaceProjectedBy();
 
 	// GH-2045, GH-425
-	@Query("select concat(?1,u.id,?2) as id from #{#entityName} u")
+	@Query("select concat(?1,u.id,?2) as identifier from #{#entityName} u")
 	List<String> findAllAndSortByFunctionResultPositionalParameter(
 			@Param("positionalParameter1") String positionalParameter1,
 			@Param("positionalParameter2") String positionalParameter2, Sort sort);
 
 	// GH-2045, GH-425
-	@Query("select concat(:namedParameter1,u.id,:namedParameter2) as id from #{#entityName} u")
+	@Query("select concat(:namedParameter1,u.id,:namedParameter2) as identifier from #{#entityName} u")
 	List<String> findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter1") String namedParameter1,
 			@Param("namedParameter2") String namedParameter2, Sort sort);
 
@@ -723,12 +720,39 @@ List<String> findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter
 	@Query("select u from User u")
 	List<UserExcerpt> findRecordProjection();
 
+	@Query("select u.firstname, LOWER(u.lastname) from User u")
+	List<UserExcerpt> findRecordProjectionWithFunctions();
+
 	@Query("select u from User u")
 	<T> List<T> findRecordProjection(Class<T> projectionType);
 
 	@Query("select u.firstname, u.lastname from User u")
 	List<UserExcerpt> findMultiselectRecordProjection();
 
+	/**
+	 * Retrieves a user age by email.
+	 */
+	@Query("select u.age from User u where u.emailAddress = ?1")
+	Optional<Integer> findAgeByAnnotatedQuery(String emailAddress);
+
+	/**
+	 * Retrieves a user address by email.
+	 */
+	@Query("select u.address from User u where u.emailAddress = ?1")
+	Optional<Address> findAddressByAnnotatedQuery(String emailAddress);
+
+	/**
+	 * Retrieves a user roles by email.
+	 */
+	@Query("select u.roles from User u where u.emailAddress = ?1")
+	Set<Role> findRolesByAnnotatedQuery(String emailAddress);
+
+	/**
+	 * Retrieves a user address city by email.
+	 */
+	@Query("select u.address.city from User u where u.emailAddress = ?1")
+	String findCityByAnnotatedQuery(String emailAddress);
+
 	@UserRoleCountProjectingQuery
 	List<UserRoleCountDtoProjection> dtoProjectionEntityAndAggregatedValue();
 
@@ -746,6 +770,15 @@ List<String> findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter
 
 	Window<User> findBy(OffsetScrollPosition position);
 
+	@Query("select u from User u where u.lastname like %:name or u.lastname like :name% ORDER BY u.lastname")
+	List<User> findAnnotatedWithParameterNameQuery(@Param("name") String lastname);
+
+	List<User> findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(@Param("l1") String l1,
+			@Param("l2") String l2);
+
+	// surface QuerydslJpaPredicateExecutor.delete(…) method
+	long delete(Predicate predicate);
+
 	@Retention(RetentionPolicy.RUNTIME)
 	@Query("select u, count(r) from User u left outer join u.roles r group by u")
 	@interface UserRoleCountProjectingQuery {
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryUnitTests.java
index 4b5ad4cf3e..bdc1a67a94 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryUnitTests.java
@@ -15,13 +15,15 @@
  */
 package org.springframework.data.jpa.repository.support;
 
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
-import static org.mockito.Mockito.when;
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
 
 import jakarta.persistence.EntityManager;
 import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.PersistenceUnitUtil;
+import jakarta.persistence.metamodel.IdentifiableType;
+import jakarta.persistence.metamodel.ManagedType;
 import jakarta.persistence.metamodel.Metamodel;
 
 import java.io.IOException;
@@ -35,6 +37,7 @@
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.mockito.junit.jupiter.MockitoSettings;
 import org.mockito.quality.Strictness;
+
 import org.springframework.aop.framework.Advised;
 import org.springframework.core.OverridingClassLoader;
 import org.springframework.data.jpa.domain.sample.User;
@@ -62,6 +65,7 @@ class JpaRepositoryFactoryUnitTests {
 	private JpaRepositoryFactory factory;
 
 	@Mock EntityManager entityManager;
+	@Mock PersistenceUnitUtil persistenceUnitUtil;
 	@Mock Metamodel metamodel;
 	@Mock
 	@SuppressWarnings("rawtypes") JpaEntityInformation entityInformation;
@@ -74,6 +78,7 @@ void setUp() {
 		when(entityManager.getEntityManagerFactory()).thenReturn(emf);
 		when(entityManager.getDelegate()).thenReturn(entityManager);
 		when(emf.createEntityManager()).thenReturn(entityManager);
+		when(emf.getPersistenceUnitUtil()).thenReturn(persistenceUnitUtil);
 
 		// Setup standard factory configuration
 		factory = new JpaRepositoryFactory(entityManager) {
@@ -140,6 +145,9 @@ void handlesCheckedExceptionsCorrectly() {
 	@Test
 	void createsProxyWithCustomBaseClass() {
 
+		when(metamodel.managedType(any()))
+				.thenReturn(mock(ManagedType.class, withSettings().extraInterfaces(IdentifiableType.class)));
+
 		JpaRepositoryFactory factory = new CustomGenericJpaRepositoryFactory(entityManager);
 		factory.setQueryLookupStrategyKey(Key.CREATE_IF_NOT_FOUND);
 		UserCustomExtendedRepository repository = factory.getRepository(UserCustomExtendedRepository.class);
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFragmentsContributorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFragmentsContributorUnitTests.java
new file mode 100644
index 0000000000..7825534a32
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFragmentsContributorUnitTests.java
@@ -0,0 +1,96 @@
+/*
+ * Copyright 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.springframework.data.jpa.repository.support;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.Mockito.*;
+
+import jakarta.persistence.EntityManager;
+
+import java.util.Iterator;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.data.jpa.domain.sample.QCustomer;
+import org.springframework.data.jpa.domain.sample.User;
+import org.springframework.data.querydsl.EntityPathResolver;
+import org.springframework.data.querydsl.QuerydslPredicateExecutor;
+import org.springframework.data.repository.Repository;
+import org.springframework.data.repository.core.RepositoryMetadata;
+import org.springframework.data.repository.core.support.AbstractRepositoryMetadata;
+import org.springframework.data.repository.core.support.RepositoryComposition;
+import org.springframework.data.repository.core.support.RepositoryFragment;
+
+import com.querydsl.core.types.EntityPath;
+
+/**
+ * Unit tests for {@link JpaRepositoryFragmentsContributor}.
+ *
+ * @author Mark Paluch
+ */
+class JpaRepositoryFragmentsContributorUnitTests {
+
+	@Test // GH-3279
+	void composedContributorShouldCreateFragments() {
+
+		JpaRepositoryFragmentsContributor contributor = JpaRepositoryFragmentsContributor.DEFAULT
+				.andThen(MyJpaRepositoryFragmentsContributor.INSTANCE);
+
+		EntityPathResolver entityPathResolver = mock(EntityPathResolver.class);
+		when(entityPathResolver.createPath(any())).thenReturn((EntityPath) QCustomer.customer);
+
+		EntityManager entityManager = mock(EntityManager.class);
+		when(entityManager.getDelegate()).thenReturn(entityManager);
+
+		RepositoryComposition.RepositoryFragments fragments = contributor.contribute(
+				AbstractRepositoryMetadata.getMetadata(QuerydslUserRepository.class),
+				new JpaEntityInformationSupportUnitTests.DummyJpaEntityInformation<>(QuerydslUserRepository.class),
+				entityManager, entityPathResolver);
+
+		assertThat(fragments).hasSize(2);
+
+		Iterator<RepositoryFragment<?>> iterator = fragments.iterator();
+
+		RepositoryFragment<?> querydsl = iterator.next();
+		assertThat(querydsl.getImplementationClass()).contains(QuerydslJpaPredicateExecutor.class);
+
+		RepositoryFragment<?> additional = iterator.next();
+		assertThat(additional.getImplementationClass()).contains(MyFragment.class);
+	}
+
+	enum MyJpaRepositoryFragmentsContributor implements JpaRepositoryFragmentsContributor {
+
+		INSTANCE;
+
+		@Override
+		public RepositoryComposition.RepositoryFragments contribute(RepositoryMetadata metadata,
+				JpaEntityInformation<?, ?> entityInformation, EntityManager entityManager, EntityPathResolver resolver) {
+			return RepositoryComposition.RepositoryFragments.just(new MyFragment());
+		}
+
+		@Override
+		public RepositoryComposition.RepositoryFragments describe(RepositoryMetadata metadata) {
+			return RepositoryComposition.RepositoryFragments.just(new MyFragment());
+		}
+	}
+
+	static class MyFragment {
+
+	}
+
+	interface QuerydslUserRepository extends Repository<User, Long>, QuerydslPredicateExecutor<User> {}
+
+}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaMetamodelEntityInformationIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaMetamodelEntityInformationIntegrationTests.java
deleted file mode 100644
index 5c0a0600dd..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaMetamodelEntityInformationIntegrationTests.java
+++ /dev/null
@@ -1,62 +0,0 @@
-/*
- * Copyright 2013-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.springframework.data.jpa.repository.support;
-
-import org.junit.jupiter.api.Disabled;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.springframework.test.context.ContextConfiguration;
-import org.springframework.test.context.junit.jupiter.SpringExtension;
-
-/**
- * OpenJpa execution for {@link JpaMetamodelEntityInformationIntegrationTests}.
- *
- * @author Oliver Gierke
- * @author Greg Turnquist
- */
-@ExtendWith(SpringExtension.class)
-@ContextConfiguration({ "classpath:infrastructure.xml", "classpath:openjpa.xml" })
-class OpenJpaMetamodelEntityInformationIntegrationTests extends JpaMetamodelEntityInformationIntegrationTests {
-
-	@Override
-	String getMetadadataPersistenceUnitName() {
-		return "metadata_oj";
-	}
-
-	/**
-	 * Re-activate test.
-	 */
-	@Test
-	void reactivatedDetectsIdTypeForMappedSuperclass() {
-		super.detectsIdTypeForMappedSuperclass();
-	}
-
-	/**
-	 * Ignore as it fails with weird {@link NoClassDefFoundError}.
-	 */
-	@Override
-	@Disabled
-	void findsIdClassOnMappedSuperclass() {}
-
-	/**
-	 * Re-activate test for DATAJPA-820.
-	 */
-	@Test
-	@Override
-	void detectsVersionPropertyOnMappedSuperClass() {
-		super.detectsVersionPropertyOnMappedSuperClass();
-	}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaProxyIdAccessorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaProxyIdAccessorTests.java
deleted file mode 100644
index 54372525c8..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaProxyIdAccessorTests.java
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
- * Copyright 2014-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.springframework.data.jpa.repository.support;
-
-import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.ImportResource;
-import org.springframework.data.jpa.provider.PersistenceProviderIntegrationTests;
-import org.springframework.test.context.ContextConfiguration;
-
-/**
- * @author Oliver Gierke
- */
-@ContextConfiguration
-class OpenJpaProxyIdAccessorTests extends PersistenceProviderIntegrationTests {
-
-	@Configuration
-	@ImportResource("classpath:openjpa.xml")
-	static class Config {}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java
index 0eecd481ae..8304430499 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java
@@ -213,7 +213,7 @@ void findBySpecificationWithSortByQueryDslOrderSpecifierWithQPageRequestAndQSort
 		QUser user = QUser.user;
 
 		Page<User> page = predicateExecutor.findAll(user.firstname.isNotNull(),
-				new QPageRequest(0, 10, new QSort(user.firstname.asc())));
+				QPageRequest.of(0, 10, new QSort(user.firstname.asc())));
 
 		assertThat(page.getContent()).containsExactly(carter, dave, oliver);
 	}
@@ -224,7 +224,7 @@ void findBySpecificationWithSortByQueryDslOrderSpecifierWithQPageRequest() {
 		QUser user = QUser.user;
 
 		Page<User> page = predicateExecutor.findAll(user.firstname.isNotNull(),
-				new QPageRequest(0, 10, user.firstname.asc()));
+				QPageRequest.of(0, 10, user.firstname.asc()));
 
 		assertThat(page.getContent()).containsExactly(carter, dave, oliver);
 	}
@@ -238,7 +238,7 @@ void findBySpecificationWithSortByQueryDslOrderSpecifierForAssociationShouldGene
 		QUser user = QUser.user;
 
 		Page<User> page = predicateExecutor.findAll(user.firstname.isNotNull(),
-				new QPageRequest(0, 10, user.manager.firstname.asc()));
+				QPageRequest.of(0, 10, user.manager.firstname.asc()));
 
 		assertThat(page.getContent()).containsExactly(carter, dave, oliver);
 	}
@@ -551,6 +551,17 @@ void findByFluentPredicateWithComplexPropertyPathsDoesntLoadsRequestedPaths() {
 		assertThat(users).allMatch(u -> u.getRoles().isEmpty());
 	}
 
+	@Test // GH-3877
+	void deleteShouldDeleteUsers() {
+
+		long deleted = predicateExecutor.delete(user.dateOfBirth.isNull());
+
+		assertThat(deleted).isEqualTo(3);
+		em.flush();
+
+		assertThat(predicateExecutor.findAll(user.dateOfBirth.isNull())).isEmpty();
+	}
+
 	private interface UserProjectionInterfaceBased {
 
 		String getFirstname();
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepositoryTests.java
deleted file mode 100644
index ece657841b..0000000000
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepositoryTests.java
+++ /dev/null
@@ -1,341 +0,0 @@
-/*
- * Copyright 2008-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.springframework.data.jpa.repository.support;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
-
-import jakarta.persistence.EntityManager;
-import jakarta.persistence.PersistenceContext;
-
-import java.sql.Date;
-import java.time.LocalDate;
-import java.util.List;
-
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.junit.jupiter.api.extension.ExtendWith;
-import org.springframework.dao.IncorrectResultSizeDataAccessException;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.data.domain.Pageable;
-import org.springframework.data.domain.Sort;
-import org.springframework.data.domain.Sort.Direction;
-import org.springframework.data.domain.Sort.Order;
-import org.springframework.data.jpa.domain.sample.Address;
-import org.springframework.data.jpa.domain.sample.QUser;
-import org.springframework.data.jpa.domain.sample.Role;
-import org.springframework.data.jpa.domain.sample.User;
-import org.springframework.data.querydsl.QPageRequest;
-import org.springframework.data.querydsl.QSort;
-import org.springframework.test.context.ContextConfiguration;
-import org.springframework.test.context.junit.jupiter.SpringExtension;
-import org.springframework.transaction.annotation.Transactional;
-
-import com.querydsl.core.types.Predicate;
-import com.querydsl.core.types.dsl.BooleanExpression;
-import com.querydsl.core.types.dsl.PathBuilder;
-import com.querydsl.core.types.dsl.PathBuilderFactory;
-
-/**
- * Integration test for {@link QuerydslJpaRepository}.
- *
- * @author Oliver Gierke
- * @author Thomas Darimont
- * @author Mark Paluch
- * @author Christoph Strobl
- * @author Malte Mauelshagen
- * @author Greg Turnquist
- * @author Krzysztof Krason
- */
-@ExtendWith(SpringExtension.class)
-@ContextConfiguration({ "classpath:infrastructure.xml" })
-@Transactional
-class QuerydslJpaRepositoryTests {
-
-	@PersistenceContext EntityManager em;
-
-	private QuerydslJpaRepository<User, Integer> repository;
-	private QUser user = new QUser("user");
-	private User dave;
-	private User carter;
-	private User oliver;
-	private Role adminRole;
-
-	@BeforeEach
-	void setUp() {
-
-		JpaEntityInformation<User, Integer> information = new JpaMetamodelEntityInformation<>(User.class, em.getMetamodel(),
-				em.getEntityManagerFactory().getPersistenceUnitUtil());
-
-		repository = new QuerydslJpaRepository<>(information, em);
-		dave = repository.save(new User("Dave", "Matthews", "dave@matthews.com"));
-		carter = repository.save(new User("Carter", "Beauford", "carter@beauford.com"));
-		oliver = repository.save(new User("Oliver", "matthews", "oliver@matthews.com"));
-		adminRole = em.merge(new Role("admin"));
-	}
-
-	@Test
-	void executesPredicatesCorrectly() {
-
-		BooleanExpression isCalledDave = user.firstname.eq("Dave");
-		BooleanExpression isBeauford = user.lastname.eq("Beauford");
-
-		List<User> result = repository.findAll(isCalledDave.or(isBeauford));
-
-		assertThat(result).containsExactlyInAnyOrder(carter, dave);
-	}
-
-	@Test
-	void executesStringBasedPredicatesCorrectly() {
-
-		PathBuilder<User> builder = new PathBuilderFactory().create(User.class);
-
-		BooleanExpression isCalledDave = builder.getString("firstname").eq("Dave");
-		BooleanExpression isBeauford = builder.getString("lastname").eq("Beauford");
-
-		List<User> result = repository.findAll(isCalledDave.or(isBeauford));
-
-		assertThat(result).containsExactlyInAnyOrder(carter, dave);
-	}
-
-	@Test // DATAJPA-243
-	void considersSortingProvidedThroughPageable() {
-
-		Predicate lastnameContainsE = user.lastname.contains("e");
-
-		Page<User> result = repository.findAll(lastnameContainsE, PageRequest.of(0, 1, Direction.ASC, "lastname"));
-
-		assertThat(result).containsExactly(carter);
-
-		result = repository.findAll(lastnameContainsE, PageRequest.of(0, 2, Direction.DESC, "lastname"));
-
-		assertThat(result).containsExactly(oliver, dave);
-	}
-
-	@Test // DATAJPA-296
-	void appliesIgnoreCaseOrdering() {
-
-		Sort sort = Sort.by(new Order(Direction.DESC, "lastname").ignoreCase(), new Order(Direction.ASC, "firstname"));
-
-		Page<User> result = repository.findAll(user.lastname.contains("e"), PageRequest.of(0, 2, sort));
-
-		assertThat(result.getContent()).containsExactly(dave, oliver);
-	}
-
-	@Test // DATAJPA-427
-	void findBySpecificationWithSortByPluralAssociationPropertyInPageableShouldUseSortNullValuesLast() {
-
-		oliver.getColleagues().add(dave);
-		dave.getColleagues().add(oliver);
-
-		QUser user = QUser.user;
-
-		Page<User> page = repository.findAll(user.firstname.isNotNull(),
-				PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "colleagues.firstname")));
-
-		assertThat(page.getContent()).hasSize(3).contains(oliver, dave, carter);
-	}
-
-	@Test // DATAJPA-427
-	void findBySpecificationWithSortBySingularAssociationPropertyInPageableShouldUseSortNullValuesLast() {
-
-		oliver.setManager(dave);
-		dave.setManager(carter);
-
-		QUser user = QUser.user;
-
-		Page<User> page = repository.findAll(user.firstname.isNotNull(),
-				PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "manager.firstname")));
-
-		assertThat(page.getContent()).hasSize(3).contains(dave, oliver, carter);
-	}
-
-	@Test // DATAJPA-427
-	void findBySpecificationWithSortBySingularPropertyInPageableShouldUseSortNullValuesFirst() {
-
-		QUser user = QUser.user;
-
-		Page<User> page = repository.findAll(user.firstname.isNotNull(),
-				PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "firstname")));
-
-		assertThat(page.getContent()).containsExactly(carter, dave, oliver);
-	}
-
-	@Test // DATAJPA-427
-	void findBySpecificationWithSortByOrderIgnoreCaseBySingularPropertyInPageableShouldUseSortNullValuesFirst() {
-
-		QUser user = QUser.user;
-
-		Page<User> page = repository.findAll(user.firstname.isNotNull(),
-				PageRequest.of(0, 10, Sort.by(new Order(Sort.Direction.ASC, "firstname").ignoreCase())));
-
-		assertThat(page.getContent()).containsExactly(carter, dave, oliver);
-	}
-
-	@Test // DATAJPA-427
-	void findBySpecificationWithSortByNestedEmbeddedPropertyInPageableShouldUseSortNullValuesFirst() {
-
-		oliver.setAddress(new Address("Germany", "Saarbrücken", "HaveItYourWay", "123"));
-
-		QUser user = QUser.user;
-
-		Page<User> page = repository.findAll(user.firstname.isNotNull(),
-				PageRequest.of(0, 10, Sort.by(Sort.Direction.ASC, "address.streetName")));
-
-		assertThat(page.getContent()).containsExactly(dave, carter, oliver);
-	}
-
-	@Test // DATAJPA-12
-	void findBySpecificationWithSortByQueryDslOrderSpecifierWithQPageRequestAndQSort() {
-
-		QUser user = QUser.user;
-
-		Page<User> page = repository.findAll(user.firstname.isNotNull(),
-				QPageRequest.of(0, 10, new QSort(user.firstname.asc())));
-
-		assertThat(page.getContent()).containsExactly(carter, dave, oliver);
-	}
-
-	@Test // DATAJPA-12
-	void findBySpecificationWithSortByQueryDslOrderSpecifierWithQPageRequest() {
-
-		QUser user = QUser.user;
-
-		Page<User> page = repository.findAll(user.firstname.isNotNull(), QPageRequest.of(0, 10, user.firstname.asc()));
-
-		assertThat(page.getContent()).containsExactly(carter, dave, oliver);
-	}
-
-	@Test // DATAJPA-12
-	void findBySpecificationWithSortByQueryDslOrderSpecifierForAssociationShouldGenerateLeftJoinWithQPageRequest() {
-
-		oliver.setManager(dave);
-		dave.setManager(carter);
-
-		QUser user = QUser.user;
-
-		Page<User> page = repository.findAll(user.firstname.isNotNull(),
-				QPageRequest.of(0, 10, user.manager.firstname.asc()));
-
-		assertThat(page.getContent()).containsExactly(carter, dave, oliver);
-	}
-
-	@Test // DATAJPA-491
-	void sortByNestedAssociationPropertyWithSpecificationAndSortInPageable() {
-
-		oliver.setManager(dave);
-		dave.getRoles().add(adminRole);
-
-		Page<User> page = repository.findAll(PageRequest.of(0, 10, Sort.by(Direction.ASC, "manager.roles.name")));
-
-		assertThat(page.getContent()).hasSize(3);
-		assertThat(page.getContent().get(0)).isEqualTo(dave);
-	}
-
-	@Test // DATAJPA-500, DATAJPA-635
-	void sortByNestedEmbeddedAttribute() {
-
-		carter.setAddress(new Address("U", "Z", "Y", "41"));
-		dave.setAddress(new Address("U", "A", "Y", "41"));
-		oliver.setAddress(new Address("G", "D", "X", "42"));
-
-		List<User> users = repository.findAll(QUser.user.address.streetName.asc());
-
-		assertThat(users).hasSize(3).contains(dave, oliver, carter);
-	}
-
-	@Test // DATAJPA-566, DATAJPA-635
-	void shouldSupportSortByOperatorWithDateExpressions() {
-
-		carter.setDateOfBirth(Date.valueOf(LocalDate.of(2000, 2, 1)));
-		dave.setDateOfBirth(Date.valueOf(LocalDate.of(2000, 1, 1)));
-		oliver.setDateOfBirth(Date.valueOf(LocalDate.of(2003, 5, 1)));
-
-		List<User> users = repository.findAll(QUser.user.dateOfBirth.yearMonth().asc());
-
-		assertThat(users).containsExactly(dave, carter, oliver);
-	}
-
-	@Test // DATAJPA-665
-	void shouldSupportExistsWithPredicate() {
-
-		assertThat(repository.exists(user.firstname.eq("Dave"))).isTrue();
-		assertThat(repository.exists(user.firstname.eq("Unknown"))).isFalse();
-		assertThat(repository.exists((Predicate) null)).isTrue();
-	}
-
-	@Test // DATAJPA-679
-	void shouldSupportFindAllWithPredicateAndSort() {
-
-		List<User> users = repository.findAll(user.dateOfBirth.isNull(), Sort.by(Direction.ASC, "firstname"));
-
-		assertThat(users).contains(carter, dave, oliver);
-	}
-
-	@Test // DATAJPA-585
-	void worksWithUnpagedPageable() {
-		assertThat(repository.findAll(user.dateOfBirth.isNull(), Pageable.unpaged()).getContent()).hasSize(3);
-	}
-
-	@Test // DATAJPA-912
-	void pageableQueryReportsTotalFromResult() {
-
-		Page<User> firstPage = repository.findAll(user.dateOfBirth.isNull(), PageRequest.of(0, 10));
-		assertThat(firstPage.getContent()).hasSize(3);
-		assertThat(firstPage.getTotalElements()).isEqualTo(3L);
-
-		Page<User> secondPage = repository.findAll(user.dateOfBirth.isNull(), PageRequest.of(1, 2));
-		assertThat(secondPage.getContent()).hasSize(1);
-		assertThat(secondPage.getTotalElements()).isEqualTo(3L);
-	}
-
-	@Test // DATAJPA-912
-	void pageableQueryReportsTotalFromCount() {
-
-		Page<User> firstPage = repository.findAll(user.dateOfBirth.isNull(), PageRequest.of(0, 3));
-		assertThat(firstPage.getContent()).hasSize(3);
-		assertThat(firstPage.getTotalElements()).isEqualTo(3L);
-
-		Page<User> secondPage = repository.findAll(user.dateOfBirth.isNull(), PageRequest.of(10, 10));
-		assertThat(secondPage.getContent()).isEmpty();
-		assertThat(secondPage.getTotalElements()).isEqualTo(3L);
-	}
-
-	@Test // DATAJPA-1115
-	void findOneWithPredicateReturnsResultCorrectly() {
-		assertThat(repository.findOne(user.eq(dave))).contains(dave);
-	}
-
-	@Test // DATAJPA-1115
-	void findOneWithPredicateReturnsOptionalEmptyWhenNoDataFound() {
-		assertThat(repository.findOne(user.firstname.eq("batman"))).isNotPresent();
-	}
-
-	@Test // DATAJPA-1115
-	void findOneWithPredicateThrowsExceptionForNonUniqueResults() {
-
-		assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class)
-				.isThrownBy(() -> repository.findOne(user.emailAddress.contains("com")));
-	}
-
-	@Test // GH-2294
-	void findByFluentQuery() {
-
-		assertThatExceptionOfType(UnsupportedOperationException.class)
-				.isThrownBy(() -> repository.findBy(user.firstname.eq("Dave"), q -> q.sortBy(Sort.by("firstname")).all()));
-	}
-}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/SimpleJpaRepositoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/SimpleJpaRepositoryUnitTests.java
index afd9634b44..3d17c347c1 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/SimpleJpaRepositoryUnitTests.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/SimpleJpaRepositoryUnitTests.java
@@ -46,6 +46,7 @@
 import org.mockito.quality.Strictness;
 
 import org.springframework.data.domain.PageRequest;
+import org.springframework.data.jpa.domain.Specification;
 import org.springframework.data.jpa.domain.sample.User;
 import org.springframework.data.jpa.repository.EntityGraph.EntityGraphType;
 import org.springframework.data.repository.CrudRepository;
@@ -144,7 +145,7 @@ void shouldPropagateConfiguredEntityGraphToFindOne() throws Exception {
 		String entityGraphName = "User.detail";
 		when(entityGraphAnnotation.value()).thenReturn(entityGraphName);
 		when(entityGraphAnnotation.type()).thenReturn(EntityGraphType.LOAD);
-		when(metadata.getEntityGraph()).thenReturn(Optional.of(entityGraphAnnotation));
+		when(metadata.getEntityGraph()).thenReturn(entityGraphAnnotation);
 		when(em.getEntityGraph(entityGraphName)).thenReturn((EntityGraph) entityGraph);
 		when(information.getEntityName()).thenReturn("User");
 		when(metadata.getMethod()).thenReturn(CrudRepository.class.getMethod("findById", Object.class));
@@ -218,7 +219,7 @@ void applyQueryHintsToCountQueriesForSpecificationPageables() {
 
 		when(query.getResultList()).thenReturn(Arrays.asList(new User(), new User()));
 
-		repo.findAll(where(null), PageRequest.of(2, 1));
+		repo.findAll(Specification.unrestricted(), PageRequest.of(2, 1));
 
 		verify(metadata).getQueryHintsForCount();
 	}
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/FixedDate.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/FixedDate.java
index a6e2800784..091c3b24f7 100644
--- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/FixedDate.java
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/FixedDate.java
@@ -15,24 +15,25 @@
  */
 package org.springframework.data.jpa.util;
 
-import java.util.Date;
+import java.time.Instant;
 
 /**
- * Holds a fixed {@link Date} value to use in components that have no direct connection.
+ * Holds a fixed {@link Instant} value to use in components that have no direct connection.
  *
  * @author Thomas Darimont
+ * @author Christoph Strobl
  */
 public enum FixedDate {
 
 	INSTANCE;
 
-	private Date fixedDate;
+	private Instant fixedDate;
 
-	public void setDate(Date date) {
+	public void setDate(Instant date) {
 		this.fixedDate = date;
 	}
 
-	public Date getDate() {
+	public Instant getDate() {
 		return fixedDate;
 	}
 }
diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java
new file mode 100644
index 0000000000..c9c2611e37
--- /dev/null
+++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java
@@ -0,0 +1,124 @@
+/*
+ * 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.springframework.data.jpa.util;
+
+import jakarta.persistence.EntityManager;
+import jakarta.persistence.EntityManagerFactory;
+import jakarta.persistence.metamodel.EmbeddableType;
+import jakarta.persistence.metamodel.EntityType;
+import jakarta.persistence.metamodel.ManagedType;
+import jakarta.persistence.metamodel.Metamodel;
+import jakarta.persistence.spi.ClassTransformer;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.hibernate.jpa.HibernatePersistenceProvider;
+import org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl;
+import org.hibernate.jpa.boot.internal.PersistenceUnitInfoDescriptor;
+import org.springframework.data.util.Lazy;
+import org.springframework.instrument.classloading.SimpleThrowawayClassLoader;
+import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo;
+
+/**
+ * @author Christoph Strobl
+ */
+public class TestMetaModel implements Metamodel {
+
+	private final String persistenceUnit;
+	private final Set<Class<?>> managedTypes;
+	private final Lazy<EntityManagerFactory> entityManagerFactory = Lazy.of(this::init);
+	private final Lazy<Metamodel> metamodel = Lazy.of(() -> entityManagerFactory.get().getMetamodel());
+	private final Lazy<EntityManager> entityManager = Lazy.of(() -> entityManagerFactory.get().createEntityManager());
+
+	private TestMetaModel(Set<Class<?>> managedTypes) {
+		this("dynamic-tests", managedTypes);
+	}
+
+	private TestMetaModel(String persistenceUnit, Set<Class<?>> managedTypes) {
+		this.persistenceUnit = persistenceUnit;
+		this.managedTypes = managedTypes;
+	}
+
+	public static TestMetaModel hibernateModel(Class<?>... types) {
+		return new TestMetaModel(Set.of(types));
+	}
+
+	public static TestMetaModel hibernateModel(String persistenceUnit, Class<?>... types) {
+		return new TestMetaModel(persistenceUnit, Set.of(types));
+	}
+
+	public <X> EntityType<X> entity(Class<X> cls) {
+		return metamodel.get().entity(cls);
+	}
+
+	@Override
+	public EntityType<?> entity(String s) {
+		return metamodel.get().entity(s);
+	}
+
+	public <X> ManagedType<X> managedType(Class<X> cls) {
+		return metamodel.get().managedType(cls);
+	}
+
+	public <X> EmbeddableType<X> embeddable(Class<X> cls) {
+		return metamodel.get().embeddable(cls);
+	}
+
+	public Set<ManagedType<?>> getManagedTypes() {
+		return metamodel.get().getManagedTypes();
+	}
+
+	public Set<EntityType<?>> getEntities() {
+		return metamodel.get().getEntities();
+	}
+
+	public Set<EmbeddableType<?>> getEmbeddables() {
+		return metamodel.get().getEmbeddables();
+	}
+
+	public EntityManager entityManager() {
+		return entityManager.get();
+	}
+
+	EntityManagerFactory init() {
+
+		MutablePersistenceUnitInfo persistenceUnitInfo = new MutablePersistenceUnitInfo() {
+			@Override
+			public ClassLoader getNewTempClassLoader() {
+				return new SimpleThrowawayClassLoader(this.getClass().getClassLoader());
+			}
+
+			@Override
+			public void addTransformer(ClassTransformer classTransformer) {
+				// just ingnore it
+			}
+		};
+
+		persistenceUnitInfo.setPersistenceUnitName(persistenceUnit);
+		this.managedTypes.stream().map(Class::getName).forEach(persistenceUnitInfo::addManagedClassName);
+
+		persistenceUnitInfo.setPersistenceProviderClassName(HibernatePersistenceProvider.class.getName());
+
+		return new EntityManagerFactoryBuilderImpl(new PersistenceUnitInfoDescriptor(persistenceUnitInfo) {
+			@Override
+			public List<String> getManagedClassNames() {
+				return persistenceUnitInfo.getManagedClassNames();
+			}
+		}, Map.of("hibernate.dialect", "org.hibernate.dialect.H2Dialect")).build();
+	}
+}
diff --git a/spring-data-jpa/src/test/resources/META-INF/orm.xml b/spring-data-jpa/src/test/resources/META-INF/orm.xml
index 820a9cced2..65f0ef28fe 100644
--- a/spring-data-jpa/src/test/resources/META-INF/orm.xml
+++ b/spring-data-jpa/src/test/resources/META-INF/orm.xml
@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<entity-mappings xmlns="http://java.sun.com/xml/ns/persistence/orm"
-	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-	xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm https://java.sun.com/xml/ns/persistence/orm_2_0.xsd"
-	version="2.0">
+<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
+				 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+				 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_2.xsd"
+				 version="3.2">
 
 	<persistence-unit-metadata>
 		<persistence-unit-defaults>
diff --git a/spring-data-jpa/src/test/resources/META-INF/persistence-jmh.xml b/spring-data-jpa/src/test/resources/META-INF/persistence-jmh.xml
index 60c6b5c97a..a78eb59468 100644
--- a/spring-data-jpa/src/test/resources/META-INF/persistence-jmh.xml
+++ b/spring-data-jpa/src/test/resources/META-INF/persistence-jmh.xml
@@ -14,9 +14,10 @@
   ~ See the License for the specific language governing permissions and
   ~ limitations under the License.
   -->
-<persistence version="2.0" xmlns="http://java.sun.com/xml/ns/persistence"
-             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence https://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
+<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
+			 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+			 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_2.xsd"
+			 version="3.2">
 	<persistence-unit name="benchmark">
 		<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
 		<class>org.springframework.data.jpa.domain.AbstractPersistable</class>
diff --git a/spring-data-jpa/src/test/resources/META-INF/persistence.xml b/spring-data-jpa/src/test/resources/META-INF/persistence.xml
index 1c3be472e0..a12c866d21 100644
--- a/spring-data-jpa/src/test/resources/META-INF/persistence.xml
+++ b/spring-data-jpa/src/test/resources/META-INF/persistence.xml
@@ -1,6 +1,10 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<persistence version="2.0" xmlns="http://java.sun.com/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence https://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
+<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
+			 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+			 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_2.xsd"
+			 version="3.2">
 	<persistence-unit name="spring-data-jpa">
+		<mapping-file>META-INF/orm.xml</mapping-file>
 		<class>org.springframework.data.jpa.domain.AbstractPersistable</class>
 		<class>org.springframework.data.jpa.domain.AbstractAuditable</class>
 		<class>org.springframework.data.jpa.domain.sample.AbstractAnnotatedAuditable</class>
@@ -66,6 +70,7 @@
 		<class>org.springframework.data.jpa.domain.sample.MailMessage</class>
 		<class>org.springframework.data.jpa.domain.sample.MailSender</class>
 		<class>org.springframework.data.jpa.domain.sample.MailUser</class>
+		<class>org.springframework.data.jpa.domain.sample.Role</class>
 		<class>org.springframework.data.jpa.domain.sample.User</class>
 		<class>org.springframework.data.jpa.domain.sample.Dummy</class>
 		<exclude-unlisted-classes>true</exclude-unlisted-classes>
@@ -75,6 +80,7 @@
 		<class>org.springframework.data.jpa.domain.sample.MailMessage</class>
 		<class>org.springframework.data.jpa.domain.sample.MailSender</class>
 		<class>org.springframework.data.jpa.domain.sample.MailUser</class>
+		<class>org.springframework.data.jpa.domain.sample.Role</class>
 		<class>org.springframework.data.jpa.domain.sample.User</class>
 		<class>org.springframework.data.jpa.repository.cdi.Person</class>
 		<class>org.springframework.data.jpa.domain.sample.Dummy</class>
@@ -92,6 +98,7 @@
 	<!-- DATAJPA-476 -->
 	<persistence-unit name="merchant">
 		<class>org.springframework.data.jpa.domain.sample.User</class>
+		<class>org.springframework.data.jpa.domain.sample.Role</class>
 		<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Merchant</class>
 		<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Address</class>
 		<class>org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Employee</class>
@@ -102,6 +109,14 @@
 		</properties>
 	</persistence-unit>
 
+	<persistence-unit name="dynamic-tests">
+		<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
+		<exclude-unlisted-classes>true</exclude-unlisted-classes>
+		<properties>
+			<property name="hibernate.dialect" value="org.hibernate.dialect.HSQLDialect" />
+		</properties>
+	</persistence-unit>
+
 	<!--  Custom PUs for metadata tests -->
 
 	<persistence-unit name="metadata">
@@ -111,6 +126,7 @@
 		<class>org.springframework.data.jpa.domain.sample.MailSender</class>
 		<class>org.springframework.data.jpa.domain.sample.MailUser</class>
 		<class>org.springframework.data.jpa.domain.sample.User</class>
+		<class>org.springframework.data.jpa.domain.sample.Role</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithNestedIdClass</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithIdClass</class>
@@ -129,6 +145,7 @@
 		<class>org.springframework.data.jpa.domain.sample.MailSender</class>
 		<class>org.springframework.data.jpa.domain.sample.MailUser</class>
 		<class>org.springframework.data.jpa.domain.sample.User</class>
+		<class>org.springframework.data.jpa.domain.sample.Role</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithNestedIdClass</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithIdClass</class>
@@ -146,24 +163,6 @@
 			<property name="eclipselink.ddl-generation.output-mode" value="database" />
 		</properties>
 	</persistence-unit>
-	<persistence-unit name="metadata_oj">
-		<provider>org.apache.openjpa.persistence.PersistenceProviderImpl</provider>
-		<class>org.springframework.data.jpa.domain.sample.CustomAbstractPersistable</class>
-		<class>org.springframework.data.jpa.domain.sample.MailMessage</class>
-		<class>org.springframework.data.jpa.domain.sample.MailSender</class>
-		<class>org.springframework.data.jpa.domain.sample.MailUser</class>
-		<class>org.springframework.data.jpa.domain.sample.User</class>
-		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample</class>
-		<class>org.springframework.data.jpa.domain.sample.Dummy</class>
-		<exclude-unlisted-classes>true</exclude-unlisted-classes>
-		<properties>
-			<property name="openjpa.jdbc.DBDictionary" value="hsql" />
-			<property name="openjpa.ConnectionDriverName" value="org.hsqldb.jdbcDriver" />
-			<property name="openjpa.ConnectionURL" value="jdbc:hsqldb:mem:test" />
-			<property name="openjpa.ConnectionUserName" value="sa" />
-			<property name="openjpa.ConnectionPassword" value="" />
-		</properties>
-	</persistence-unit>
 	<persistence-unit name="metadata-id-handling">
 		<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
 		<class>org.springframework.data.jpa.domain.sample.CustomAbstractPersistable</class>
@@ -171,6 +170,7 @@
 		<class>org.springframework.data.jpa.domain.sample.MailSender</class>
 		<class>org.springframework.data.jpa.domain.sample.MailUser</class>
 		<class>org.springframework.data.jpa.domain.sample.User</class>
+		<class>org.springframework.data.jpa.domain.sample.Role</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithNestedIdClass</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithIdClass</class>
@@ -188,6 +188,7 @@
 		<class>org.springframework.data.jpa.domain.sample.MailMessage</class>
 		<class>org.springframework.data.jpa.domain.sample.MailSender</class>
 		<class>org.springframework.data.jpa.domain.sample.MailUser</class>
+		<class>org.springframework.data.jpa.domain.sample.Role</class>
 		<class>org.springframework.data.jpa.domain.sample.User</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample</class>
 		<class>org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithNestedIdClass</class>
diff --git a/spring-data-jpa/src/test/resources/META-INF/persistence2.xml b/spring-data-jpa/src/test/resources/META-INF/persistence2.xml
index f4f7adb6b2..a93617de58 100644
--- a/spring-data-jpa/src/test/resources/META-INF/persistence2.xml
+++ b/spring-data-jpa/src/test/resources/META-INF/persistence2.xml
@@ -1,7 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<persistence version="2.0"
-	xmlns="http://java.sun.com/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
-	xsi:schemaLocation="http://java.sun.com/xml/ns/persistence https://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
+<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
+			 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+			 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_2.xsd"
+			 version="3.2">
 	<persistence-unit name="first">
 		<class>org.springframework.data.jpa.domain.sample.AnnotatedAuditableUser</class>
 		<class>org.springframework.data.jpa.domain.sample.AuditableRole</class>
diff --git a/spring-data-jpa/src/test/resources/application-context.xml b/spring-data-jpa/src/test/resources/application-context.xml
index 1bd58b22cd..3f10133b5d 100644
--- a/spring-data-jpa/src/test/resources/application-context.xml
+++ b/spring-data-jpa/src/test/resources/application-context.xml
@@ -25,12 +25,10 @@
 				</constructor-arg>
 			</bean>
 		</property>
-		<property name="evaluationContextProvider" ref="expressionEvaluationContextProvider"/>
 	</bean>
-	
+
 	<bean id="roleDao" class="org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean">
 		<constructor-arg value="org.springframework.data.jpa.repository.sample.RoleRepository" />
-		<property name="evaluationContextProvider" ref="expressionEvaluationContextProvider"/>
 	</bean>
 
 	<!-- Necessary to get the entity manager injected into the factory bean -->
@@ -39,8 +37,6 @@
 	<!-- Adds transparent exception translation to the DAOs -->
 	<bean class="org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor" />
 
-	<bean id="expressionEvaluationContextProvider" class="org.springframework.data.repository.query.ExtensionAwareQueryMethodEvaluationContextProvider" autowire="constructor" />
-	
 	<bean class="org.springframework.data.jpa.repository.support.EntityManagerBeanDefinitionRegistrarPostProcessor" />
 
 	<bean class="org.springframework.data.jpa.repository.GreetingsFrom" name="greetingsFrom" />
diff --git a/spring-data-jpa/src/test/resources/logback.xml b/spring-data-jpa/src/test/resources/logback.xml
index 19bb933f9c..b16caaa18c 100644
--- a/spring-data-jpa/src/test/resources/logback.xml
+++ b/spring-data-jpa/src/test/resources/logback.xml
@@ -19,6 +19,9 @@
 
 <!--	<logger name="org.testcontainers" level="debug" />-->
 
+	<logger name="org.springframework.data.repository.aot.generate.RepositoryContributor"
+	        level="warn"/>
+
 	<root level="error">
 		<appender-ref ref="console"/>
 	</root>
diff --git a/spring-data-jpa/src/test/resources/openjpa.xml b/spring-data-jpa/src/test/resources/openjpa.xml
deleted file mode 100644
index eaca2061cd..0000000000
--- a/spring-data-jpa/src/test/resources/openjpa.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<beans xmlns="http://www.springframework.org/schema/beans"
-	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:jdbc="http://www.springframework.org/schema/jdbc"
-	xmlns:util="http://www.springframework.org/schema/util"
-	xsi:schemaLocation="http://www.springframework.org/schema/beans https://www.springframework.org/schema/beans/spring-beans.xsd
-		http://www.springframework.org/schema/util https://www.springframework.org/schema/util/spring-util.xsd
-		http://www.springframework.org/schema/jdbc https://www.springframework.org/schema/jdbc/spring-jdbc.xsd">
-
-	<!-- EclipseLink vendor adaptor with workaround platform class for HSQL 
-		usage -->
-	<bean id="vendorAdaptor" class="org.springframework.orm.jpa.vendor.OpenJpaVendorAdapter"
-		parent="abstractVendorAdaptor">
-		<property name="database" value="HSQL" />
-	</bean>
-
-	<util:properties id="jpaProperties">
-		<prop key="openjpa.Log">none</prop>
-	</util:properties>
-
-	<!-- Needed to override dataSource definition from infrastructure.xml to 
-		make OpenJPA tests work. Open JPA doesn't work with hsqldb 2.x and runs with 
-		1.x instead which doesn't support stored procedures which leads to errors 
-		at runtime when the scripts/schema-stored-procedure.sql is executed, therefore we omit the script here. -->
-	<jdbc:embedded-database id="dataSource" type="HSQL" generate-name="true"/>
-</beans>
diff --git a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/mapping.xml b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/mapping.xml
index 87f3460858..634c42b966 100644
--- a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/mapping.xml
+++ b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/mapping.xml
@@ -1,2 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<entity-mappings xmlns="http://java.sun.com/xml/ns/persistence/orm" />
+<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
+                 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+                 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_2.xsd"
+                 version="3.2" />
diff --git a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/module1/module1-orm.xml b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/module1/module1-orm.xml
index da1ce9a7d4..634c42b966 100644
--- a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/module1/module1-orm.xml
+++ b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/module1/module1-orm.xml
@@ -1,3 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<entity-mappings xmlns="http://java.sun.com/xml/ns/persistence/orm" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm https://java.sun.com/xml/ns/persistence/orm_2_0.xsd" version="2.0">
-</entity-mappings>
+<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
+                 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+                 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_2.xsd"
+                 version="3.2" />
diff --git a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/module2/module2-orm.xml b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/module2/module2-orm.xml
index da1ce9a7d4..634c42b966 100644
--- a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/module2/module2-orm.xml
+++ b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/module2/module2-orm.xml
@@ -1,3 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<entity-mappings xmlns="http://java.sun.com/xml/ns/persistence/orm" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence/orm https://java.sun.com/xml/ns/persistence/orm_2_0.xsd" version="2.0">
-</entity-mappings>
+<entity-mappings xmlns="https://jakarta.ee/xml/ns/persistence/orm"
+                 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+                 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence/orm https://jakarta.ee/xml/ns/persistence/orm/orm_3_2.xsd"
+                 version="3.2" />
diff --git a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/persistence.xml b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/persistence.xml
index ad1460bad7..f75fea5ba3 100644
--- a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/persistence.xml
+++ b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/persistence.xml
@@ -1,5 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<persistence version="1.0" xmlns="http://java.sun.com/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence https://java.sun.com/xml/ns/persistence/persistence_1_0.xsd">
+<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
+			 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+			 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_2.xsd"
+			 version="3.2">
 	<persistence-unit name="pu">
 		<mapping-file>foo.xml</mapping-file>
 		<class>org.springframework.data.jpa.domain.sample.User</class>
diff --git a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/persistence2.xml b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/persistence2.xml
index 962748440b..1666022d07 100644
--- a/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/persistence2.xml
+++ b/spring-data-jpa/src/test/resources/org/springframework/data/jpa/support/persistence2.xml
@@ -1,5 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<persistence version="1.0" xmlns="http://java.sun.com/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence https://java.sun.com/xml/ns/persistence/persistence_1_0.xsd">
+<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
+			 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+			 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_2.xsd"
+			 version="3.2">
 	<persistence-unit name="pu">
 		<mapping-file>bar.xml</mapping-file>
 		<class>org.springframework.data.jpa.domain.sample.Role</class>
diff --git a/spring-data-jpa/src/test/resources/simple-persistence/simple-persistence.xml b/spring-data-jpa/src/test/resources/simple-persistence/simple-persistence.xml
index 9caa71259a..706d5fb919 100644
--- a/spring-data-jpa/src/test/resources/simple-persistence/simple-persistence.xml
+++ b/spring-data-jpa/src/test/resources/simple-persistence/simple-persistence.xml
@@ -1,5 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<persistence version="2.0" xmlns="http://java.sun.com/xml/ns/persistence" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/persistence https://java.sun.com/xml/ns/persistence/persistence_2_0.xsd">
+<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
+			 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+			 xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence https://jakarta.ee/xml/ns/persistence/persistence_3_2.xsd"
+			 version="3.2">
 
 	<persistence-unit name="xxx">
 		<exclude-unlisted-classes>true</exclude-unlisted-classes>
diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc
index 1e44d61f58..351c162366 100644
--- a/src/main/antora/modules/ROOT/nav.adoc
+++ b/src/main/antora/modules/ROOT/nav.adoc
@@ -25,6 +25,7 @@
 ** xref:repositories/core-extensions.adoc[]
 ** xref:repositories/query-keywords-reference.adoc[]
 ** xref:repositories/query-return-types-reference.adoc[]
+** xref:jpa/aot.adoc[]
 ** xref:jpa/faq.adoc[]
 ** xref:jpa/glossary.adoc[]
 
diff --git a/src/main/antora/modules/ROOT/pages/index.adoc b/src/main/antora/modules/ROOT/pages/index.adoc
index 4f9d18adce..37753da700 100644
--- a/src/main/antora/modules/ROOT/pages/index.adoc
+++ b/src/main/antora/modules/ROOT/pages/index.adoc
@@ -2,7 +2,6 @@
 = Spring Data JPA
 :revnumber: {version}
 :revdate: {localdate}
-:feature-scroll: true
 
 _Spring Data JPA provides repository support for the Jakarta Persistence API (JPA).
 It eases development of applications with a consistent programming model that need to access JPA data sources._
@@ -15,7 +14,7 @@ Upgrade Notes, Supported Versions, additional cross-version information.
 
 Oliver Gierke, Thomas Darimont, Christoph Strobl, Mark Paluch, Jay Bryant, Greg Turnquist
 
-(C) 2008-2025 VMware, Inc.
+(C) 2008-{copyright-year} VMware, Inc.
 
 Copies of this document may be made for your own use and for distribution to others, provided that you do not charge any fee for such copies and further provided that each copy contains this Copyright Notice, whether distributed in print or electronically.
 
diff --git a/src/main/antora/modules/ROOT/pages/jpa/aot.adoc b/src/main/antora/modules/ROOT/pages/jpa/aot.adoc
new file mode 100644
index 0000000000..031a75f527
--- /dev/null
+++ b/src/main/antora/modules/ROOT/pages/jpa/aot.adoc
@@ -0,0 +1,203 @@
+= Ahead of Time Optimizations
+
+This chapter covers Spring Data's Ahead of Time (AOT) optimizations that build upon {spring-framework-docs}/core/aot.html[Spring's Ahead of Time Optimizations].
+
+[[aot.bestpractices]]
+== Best Practices
+
+=== Annotate your Domain Types
+
+During application startup, Spring scans the classpath for domain classes for early processing of entities.
+By annotating your domain types with Spring Data-specific `@Table`, `@Document` or `@Entity` annotations you can aid initial entity scanning and ensure that those types are registered with `ManagedTypes` for Runtime Hints.
+Classpath scanning is not possible in native image arrangements and so Spring has to use `ManagedTypes` for the initial entity set.
+
+[[aot.hints]]
+== Runtime Hints
+
+Running an application as a native image requires additional information compared to a regular JVM runtime.
+Spring Data contributes {spring-framework-docs}/core/aot.html#aot.hints[Runtime Hints] during AOT processing for native image usage.
+These are in particular hints for:
+
+* Auditing
+* `ManagedTypes` to capture the outcome of class-path scans
+* Repositories
+** Reflection hints for entities, return types, and Spring Data annotations
+** Repository fragments
+** Querydsl `Q` classes
+** Kotlin Coroutine support
+* Web support (Jackson Hints for `PagedModel`)
+
+[[aot.repositories]]
+== Ahead of Time Repositories
+
+AOT Repositories are an extension to AOT processing by pre-generating eligible query method implementations.
+Query methods are opaque to developers regarding their underlying queries being executed in a query method call.
+AOT repositories contribute query method implementations based on derived, annotated, and named queries that are known at build-time.
+This optimization moves query method processing from runtime to build-time, which can lead to a significant performance improvement as query methods do not need to be analyzed reflectively upon each application start.
+
+The resulting AOT repository fragment follows the naming scheme of `<Repository FQCN>Impl__Aot` and is placed in the same package as the repository interface.
+You can find all queries in their String form for generated repository query methods.
+
+NOTE: Consider AOT repository classes an internal optimization.
+Do not use them directly in your code as generation and implementation details may change in future releases.
+
+=== Running with AOT Repositories
+
+AOT is a mandatory step to transform a Spring application to a native executable, so it is automatically enabled when running in this mode.
+It is also possible to use those optimizations on the JVM by setting the `spring.aot.enabled` and `spring.aot.repositories.enabled` properties to `true`.
+
+AOT repositories contribute configuration changes to the actual repository bean registration to register the generated repository fragment.
+
+NOTE: When AOT optimizations are included, some decisions that have been taken at build-time are hard-coded in the application setup.
+For instance, profiles that have been enabled at build-time are automatically enabled at runtime as well.
+Also, the Spring Data module implementing a repository is fixed.
+Changing the implementation requires AOT re-processing.
+
+=== Eligible Methods
+
+AOT repositories filter methods that are eligible for AOT processing.
+These are typically all query methods that are not backed by an xref:repositories/custom-implementations.adoc[implementation fragment].
+
+**Supported Features**
+
+* Derived query methods, `@Query`/`@NativeQuery` and named query methods
+* `@Modifying` methods returning `void` or `int`
+* `@QueryHints` support
+* Pagination, `Slice`, `Stream`, and `Optional` return types
+* Sort query rewriting
+* DTO Projections
+* Value Expressions (Those require a bit of reflective information.
+Mind that using Value Expressions requires expression parsing and contextual information to evaluate the expression)
+
+
+**Limitations**
+
+* Requires Hibernate for AOT processing.
+* Configuration of `escapeCharacter` and `queryEnhancerSelector` are not yet considered
+* `QueryRewriter` must be a no-args class. `QueryRewriter` beans are not yet supported.
+* Methods accepting `ScrollPosition` (e.g. `Keyset` pagination) are not yet supported
+
+**Excluded methods**
+
+* `CrudRepository` and other base interface methods
+* Querydsl and Query by Example methods
+* Methods whose implementation would be overly complex
+** Methods accepting `ScrollPosition` (e.g. `Keyset` pagination)
+** Stored procedure query methods annotated with `@Procedure`
+** Dynamic projections
+
+[[aot.repositories.json]]
+== Repository Metadata
+
+AOT processing introspects query methods and collects metadata about repository queries.
+Spring Data JPA stores this metadata in JSON files that are named like the repository interface and stored next to it (i.e. within the same package).
+Repository JSON Metadata contains details about queries and fragments.
+An example for the following repository is shown below:
+
+====
+[source,java]
+----
+interface UserRepository extends CrudRepository<User, Integer> {
+
+  List<User> findUserNoArgumentsBy();                                                  <1>
+
+  Page<User> findPageOfUsersByLastnameStartingWith(String lastname, Pageable page);    <2>
+
+  @Query("select u from User u where u.emailAddress = ?1")
+  User findAnnotatedQueryByEmailAddress(String username);                              <3>
+
+  User findByEmailAddress(String emailAddress);                                        <4>
+
+  @Procedure(value = "sp_add")
+  Integer providedProcedure(@Param("arg") Integer arg);                                <5>
+}
+----
+
+<1> Derived query without arguments.
+<2> Derived query using pagination.
+<3> Annotated query.
+<4> Named query.
+<5> Stored procedure with a provided procedure name.
+While stored procedure methods are included in JSON metadata, their method code blocks are not generated in AOT repositories.
+====
+
+[source,json]
+----
+{
+  "name": "com.acme.UserRepository",
+  "module": "",
+  "type": "IMPERATIVE",
+  "methods": [
+    {
+      "name": "findUserNoArgumentsBy",
+      "signature": "public abstract java.util.List<com.acme.User> com.acme.UserRepository.findUserNoArgumentsBy()",
+      "query": {
+        "query": "SELECT u FROM com.acme.User u"
+      }
+    },
+    {
+      "name": "findPageOfUsersByLastnameStartingWith",
+      "signature": "public abstract org.springframework.data.domain.Page<com.acme.User> com.acme.UserRepository.findPageOfUsersByLastnameStartingWith(java.lang.String,org.springframework.data.domain.Pageable)",
+      "query": {
+        "query": "SELECT u FROM com.acme.User u WHERE u.lastname LIKE ?1 ESCAPE '\\'",
+        "count-query": "SELECT COUNT(u) FROM com.acme.User u WHERE u.lastname LIKE ?1 ESCAPE '\\'"
+      }
+    },
+    {
+      "name": "findAnnotatedQueryByEmailAddress",
+      "signature": "public abstract com.acme.User com.acme.UserRepository.findAnnotatedQueryByEmailAddress(java.lang.String)",
+      "query": {
+        "query": "select u from User u where u.emailAddress = ?1"
+      }
+    },
+    {
+      "name": "findByEmailAddress",
+      "signature": "public abstract com.acme.User com.acme.UserRepository.findByEmailAddress(java.lang.String)",
+      "query": {
+        "name": "User.findByEmailAddress",
+        "query": "SELECT u FROM User u WHERE u.emailAddress = ?1"
+      }
+    },
+    {
+      "name": "providedProcedure",
+      "signature": "public abstract java.lang.Integer com.acme.UserRepository.providedProcedure(java.lang.Integer)",
+      "query": {
+        "procedure": "sp_add"
+      }
+    },
+    {
+      "name": "count",
+      "signature": "public abstract long org.springframework.data.repository.CrudRepository.count()",
+      "fragment": {
+        "fragment": "org.springframework.data.jpa.repository.support.SimpleJpaRepository"
+      }
+    }
+  ]
+}
+----
+
+Queries may contain the following fields:
+
+* `query`: Query descriptor if the method is a query method.
+** `name`: Name of the named query if the query is a named one.
+** `query` the query used to obtain the query method result from `EntityManager`
+** `count-name`: Name of the named count query if the count query is a named one.
+** `count-query`: The count query used to obtain the count for query methods using pagination.
+** `procedure-name`: Name of the named stored procedure if the stored procedure is a named one.
+** `procedure`: Stored procedure name if the query method uses stored procedures.
+* `fragment`: Target fragment if the method call is delegated to a store (repository base class, functional fragment such as Querydsl) or user fragment.
+Fragments are either described with just `fragment` if there is no further interface or as `interface` and `fragment` tuple in case there is an interface (such as Querydsl or user-declared fragment interface).
+
+[NOTE]
+.Normalized Query Form
+====
+Static analysis of queries allows only a limited representation of runtime query behavior.
+Queries are represented in their normalized (pre-parsed and rewritten) form:
+
+* Value Expressions are replaced with bind markers.
+* Queries follow the specified query language (JPQL or native) and do not represent the final SQL query.
+Spring Data cannot derive the final SQL queries as this is database-specific and depends on the actual runtime environment and parameters (e.g. Entity Graphs, Lazy Loading).
+* Query Metadata does not reflect bind-value processing.
+`StartingWith`/`EndingWith` queries prepend/append the wildcard character `%` to the actual bind value.
+* Runtime Sort information cannot be incorporated in the query string itself as that detail is not known at build-time.
+====
diff --git a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc
index e2f6112c82..b947fca73f 100644
--- a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc
+++ b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc
@@ -32,7 +32,7 @@ public interface UserRepository extends Repository<User, Long> {
   List<User> findByEmailAddressAndLastname(String emailAddress, String lastname);
 }
 ----
-We create a query using the JPA criteria API from this, but, essentially, this translates into the following query: `select u from User u where u.emailAddress = ?1 and u.lastname = ?2`. Spring Data JPA does a property check and traverses nested properties, as described in xref:repositories/query-methods-details.adoc#repositories.query-methods.query-property-expressions[Property Expressions].
+We create a query using JPQL  translating into the following query: `select u from User u where u.emailAddress = ?1 and u.lastname = ?2`. Spring Data JPA does a property check and traverses nested properties, as described in xref:repositories/query-methods-details.adoc#repositories.query-methods.query-property-expressions[Property Expressions].
 ====
 
 The following table describes the keywords supported for JPA and what a method containing that keyword translates to:
@@ -170,89 +170,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
 ----
 ====
 
-[[jpa.query-methods.query-rewriter]]
-=== Applying a QueryRewriter
-
-Sometimes, no matter how many features you try to apply, it seems impossible to get Spring Data JPA to apply every thing
-you'd like to a query before it is sent to the `EntityManager`.
-
-You have the ability to get your hands on the query, right before it's sent to the `EntityManager` and "rewrite" it.
-That is, you can make any alterations at the last moment.
-Query rewriting applies to the actual query and, when applicable, to count queries.
-Count queries are optimized and therefore, either not necessary or a count is obtained through other means, such as derived from a Hibernate `SelectionQuery`.
-
-
-.Declare a QueryRewriter using `@Query`
-====
-[source, java]
-----
-public interface MyRepository extends JpaRepository<User, Long> {
-
-		@NativeQuery(value = "select original_user_alias.* from SD_USER original_user_alias",
-				queryRewriter = MyQueryRewriter.class)
-		List<User> findByNativeQuery(String param);
-
-		@Query(value = "select original_user_alias from User original_user_alias",
-                queryRewriter = MyQueryRewriter.class)
-		List<User> findByNonNativeQuery(String param);
-}
-----
-====
-
-This example shows both a native (pure SQL) rewriter as well as a JPQL query, both leveraging the same `QueryRewriter`.
-In this scenario, Spring Data JPA will look for a bean registered in the application context of the corresponding type.
-
-You can write a query rewriter like this:
-
-.Example `QueryRewriter`
-====
-[source, java]
-----
-public class MyQueryRewriter implements QueryRewriter {
-
-     @Override
-     public String rewrite(String query, Sort sort) {
-         return query.replaceAll("original_user_alias", "rewritten_user_alias");
-     }
-}
-----
-====
-
-You have to ensure your `QueryRewriter` is registered in the application context, whether it's by applying one of Spring Framework's
-`@Component`-based annotations, or having it as part of a `@Bean` method inside an `@Configuration` class.
-
-Another option is to have the repository itself implement the interface.
-
-.Repository that provides the `QueryRewriter`
-====
-[source, java]
-----
-public interface MyRepository extends JpaRepository<User, Long>, QueryRewriter {
-
-		@Query(value = "select original_user_alias.* from SD_USER original_user_alias",
-                nativeQuery = true,
-				queryRewriter = MyRepository.class)
-		List<User> findByNativeQuery(String param);
-
-		@Query(value = "select original_user_alias from User original_user_alias",
-                queryRewriter = MyRepository.class)
-		List<User> findByNonNativeQuery(String param);
-
-		@Override
-		default String rewrite(String query, Sort sort) {
-			return query.replaceAll("original_user_alias", "rewritten_user_alias");
-		}
-}
-----
-====
-
-Depending on what you're doing with your `QueryRewriter`, it may be advisable to have more than one, each registered with the
-application context.
-
-NOTE: In a CDI-based environment, Spring Data JPA will search the `BeanManager` for instances of your implementation of
-`QueryRewriter`.
-
-
 [[jpa.query-methods.at-query.advanced-like]]
 === Using Advanced `LIKE` Expressions
 
@@ -308,23 +225,12 @@ public interface UserRepository extends JpaRepository<User, Long> {
 ----
 ====
 
-[TIP]
-====
-It is possible to disable usage of `JSqlParser` for parsing native queries although it is available on the classpath by setting `spring.data.jpa.query.native.parser=regex` via the `spring.properties` file or a system property.
-
-Valid values are (case-insensitive):
-
-* `auto` (default, automatic selection)
-* `regex` (Use the builtin regex-based Query Enhancer)
-* `jsqlparser` (Use JSqlParser)
-====
-
 A similar approach also works with named native queries, by adding the `.count` suffix to a copy of your query. You probably need to register a result set mapping for your count query, though.
 
 Next to obtaining mapped results, native queries allow you to read the raw `Tuple` from the database by choosing a `Map` container as the method's return type.
 The resulting map contains key/value pairs representing the actual database column name and the value.
 
-.Native query retuning raw column name/value pairs
+.Native query returning raw column name/value pairs
 ====
 [source, java]
 ----
@@ -344,8 +250,122 @@ interface UserRepository extends JpaRepository<User, Long> {
 NOTE: String-based Tuple Queries are only supported by Hibernate.
 Eclipselink supports only Criteria-based Tuple Queries.
 
-[[jpa.query-methods.at-query.projections]]
+[[jpa.query-methods.query-introspection-rewriting]]
+=== Query Introspection and Rewriting
 
+Spring Data JPA provides a wide range of functionality that can be used to run various flavors of queries.
+Specifically, given a declared query, Spring Data JPA can:
+
+* Introspect a query for its projection and run a tuple query for interface projections
+* Use DTO projections if the query uses constructor expressions and rewrite the projection when the query declares the entity alias or just a multi-select of expressions
+* Apply dynamic sorting
+* Derive a `COUNT` query
+
+For this purpose, we ship with Query Parsers specific to HQL (Hibernate) and EQL (EclipseLink) dialects as these dialects are well-defined.
+SQL on the other hand allows for quite some variance across dialects.
+Because of this, there is no way Spring Data will ever be able to support all levels of query complexity.
+We are not general purpose SQL parser library but one to increase developer productivity through making query execution simpler.
+Our built-in SQL query enhancer supports only simple queries for introspection `COUNT` query derivation.
+A more complex query will require either the usage of link:https://github.com/JSQLParser/JSqlParser[JSqlParser] or that you provide a `COUNT` query through `@Query(countQuery=…)`.
+If JSqlParser is on the class path, Spring Data JPA will use it for native queries.
+
+For a fine-grained control over selection, you can configure javadoc:org.springframework.data.jpa.repository.query.QueryEnhancerSelector[] using `@EnableJpaRepositories`:
+
+.Spring Data JPA repositories using JavaConfig
+====
+[source,java]
+----
+@Configuration
+@EnableJpaRepositories(queryEnhancerSelector = MyQueryEnhancerSelector.class)
+class ApplicationConfig {
+  // …
+}
+----
+====
+
+`QueryEnhancerSelector` is a strategy interface intended to select a javadoc:org.springframework.data.jpa.repository.query.QueryEnhancer[] based on a specific query.
+You can also provide your own `QueryEnhancer` implementation if you want.
+
+[[jpa.query-methods.query-rewriter]]
+=== Applying a QueryRewriter
+
+Sometimes, no matter how many features you try to apply, it seems impossible to get Spring Data JPA to apply every thing you'd like to a query before it is sent to the `EntityManager`.
+
+You have the ability to get your hands on the query, right before it's sent to the `EntityManager` and "rewrite" it.
+That is, you can make any alterations at the last moment.
+Query rewriting applies to the actual query and, when applicable, to count queries.
+Count queries are optimized and therefore, either not necessary or a count is obtained through other means, such as derived from a Hibernate `SelectionQuery` if there is an enclosing transaction.
+
+.Declare a QueryRewriter using `@Query`
+====
+[source,java]
+----
+public interface MyRepository extends JpaRepository<User, Long> {
+
+		@NativeQuery(value = "select original_user_alias.* from SD_USER original_user_alias",
+				queryRewriter = MyQueryRewriter.class)
+		List<User> findByNativeQuery(String param);
+
+		@Query(value = "select original_user_alias from User original_user_alias",
+                queryRewriter = MyQueryRewriter.class)
+		List<User> findByNonNativeQuery(String param);
+}
+----
+====
+
+This example shows both a native (pure SQL) rewriter as well as a JPQL query, both leveraging the same `QueryRewriter`.
+In this scenario, Spring Data JPA will look for a bean registered in the application context of the corresponding type.
+
+You can write a query rewriter like this:
+
+.Example `QueryRewriter`
+====
+[source,java]
+----
+public class MyQueryRewriter implements QueryRewriter {
+
+     @Override
+     public String rewrite(String query, Sort sort) {
+         return query.replaceAll("original_user_alias", "rewritten_user_alias");
+     }
+}
+----
+====
+
+You have to ensure your `QueryRewriter` is registered in the application context, whether it's by applying one of Spring Framework's
+`@Component`-based annotations, or having it as part of a `@Bean` method inside an `@Configuration` class.
+
+Another option is to have the repository itself implement the interface.
+
+.Repository that provides the `QueryRewriter`
+====
+[source,java]
+----
+public interface MyRepository extends JpaRepository<User, Long>, QueryRewriter {
+
+		@Query(value = "select original_user_alias.* from SD_USER original_user_alias",
+                nativeQuery = true,
+				queryRewriter = MyRepository.class)
+		List<User> findByNativeQuery(String param);
+
+		@Query(value = "select original_user_alias from User original_user_alias",
+                queryRewriter = MyRepository.class)
+		List<User> findByNonNativeQuery(String param);
+
+		@Override
+		default String rewrite(String query, Sort sort) {
+			return query.replaceAll("original_user_alias", "rewritten_user_alias");
+		}
+}
+----
+====
+
+Depending on what you're doing with your `QueryRewriter`, it may be advisable to have more than one, each registered with the application context.
+
+NOTE: In a CDI-based environment, Spring Data JPA will search the `BeanManager` for instances of your implementation of
+`QueryRewriter`.
+
+[[jpa.query-methods.at-query.projections]]
 [[jpa.query-methods.sorting]]
 == Using Sort
 
@@ -383,6 +403,17 @@ Throws Exception.
 <4> Valid `Sort` expression pointing to aliased function.
 ====
 
+=== JpaSort.unsafe(…) limitations
+
+`JpaSort.unsafe(…)` operates in two modes:
+
+* When used with derived Queries or String-based Queries, the order string is appended to the query.
+* When used with Query by Example or Specifications (that use `CriteriaQuery`), order expressions are parsed and added to the `CriteriaQuery` as expressions.
+Query expressions can contain function calls, various clauses (such as `CASE WHEN`, arithmetic expressions) or property paths.
+Order translation does not support subquery expressions, `TREAT` and `CAST`.`
+
+[[jpa.query-methods.paging]]
+
 [[jpa.query-methods.scroll]]
 == Scrolling Large Query Results
 
@@ -429,14 +460,14 @@ NOTE: The method parameters are switched according to their order in the defined
 NOTE: As of version 4, Spring fully supports Java 8’s parameter name discovery based on the `-parameters` compiler flag. By using this flag in your build as an alternative to debug information, you can omit the `@Param` annotation for named parameters.
 
 [[jpa.query.spel-expressions]]
-== Using Expressions
+== Templated Queries and Expressions
 
 We support the usage of restricted expressions in manually defined queries that are defined with `@Query`.
 Upon the query being run, these expressions are evaluated against a predefined set of variables.
 
 NOTE: If you are not familiar with Value Expressions, please refer to xref:jpa/value-expressions.adoc[] to learn about SpEL Expressions and Property Placeholders.
 
-Spring Data JPA supports a variable called `entityName`.
+Spring Data JPA supports a template variable called `entityName`.
 Its usage is `select x from #{#entityName} x`.
 It inserts the `entityName` of the domain type associated with the given repository.
 The `entityName` is resolved as follows:
@@ -506,7 +537,7 @@ public interface ConcreteRepository
 
 In the preceding example, the `MappedTypeRepository` interface is the common parent interface for a few domain types extending `AbstractMappedType`.
 It also defines the generic `findAllByAttribute(…)` method, which can be used on instances of the specialized repository interfaces.
-If you now invoke `findByAllAttribute(…)` on `ConcreteRepository`, the query becomes `select t from ConcreteType t where t.attribute = ?1`.
+If you now invoke `findAllByAttribute(…)` on `ConcreteRepository`, the query becomes `select t from ConcreteType t where t.attribute = ?1`.
 
 You can also use Expressions to control arguments may also be used to control method arguments.
 In these expressions the entity name is not available, but the arguments are.
diff --git a/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc b/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc
index 251542dbff..754f08c357 100644
--- a/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc
+++ b/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc
@@ -38,20 +38,20 @@ Maven::
             <groupId>org.apache.maven.plugins</groupId>
             <artifactId>maven-compiler-plugin</artifactId>
             <configuration>
-            <annotationProcessorPaths>
-                <!-- Explicit opt-in required via annotationProcessors or
+                <annotationProcessorPaths>
+                    <!-- Explicit opt-in required via annotationProcessors or
                         annotationProcessorPaths on Java 22+, see https://bugs.openjdk.org/browse/JDK-8306819 -->
-                <annotationProcessorPath>
-                    <groupId>com.querydsl</groupId>
-                    <artifactId>querydsl-apt</artifactId>
-                    <version>${querydslVersion}</version>
-                    <classifier>jakarta</classifier>
-                </annotationProcessorPath>
-                <annotationProcessorPath>
-                    <groupId>jakarta.persistence</groupId>
-                    <artifactId>jakarta.persistence-api</artifactId>
-                </annotationProcessorPath>
-              </annotationProcessorPaths>
+                    <annotationProcessorPath>
+                        <groupId>com.querydsl</groupId>
+                        <artifactId>querydsl-apt</artifactId>
+                        <version>${querydslVersion}</version>
+                        <classifier>jakarta</classifier>
+                    </annotationProcessorPath>
+                    <annotationProcessorPath>
+                        <groupId>jakarta.persistence</groupId>
+                        <artifactId>jakarta.persistence-api</artifactId>
+                    </annotationProcessorPath>
+                </annotationProcessorPaths>
 
                 <!-- Recommended: Some IDE's might require this configuration to include generated sources for IDE usage -->
                 <generatedTestSourcesDirectory>target/generated-test-sources</generatedTestSourcesDirectory>
@@ -78,6 +78,64 @@ dependencies {
 }
 ----
 ====
+
+Maven (OpenFeign)::
++
+[source,xml,indent=0,subs="verbatim,quotes",role="primary"]
+----
+<dependencies>
+    <dependency>
+        <groupId>io.github.openfeign.querydsl</groupId>
+        <artifactId>querydsl-jpa</artifactId>
+        <version>${querydslVersion}</version>
+    </dependency>
+</dependencies>
+
+<build>
+    <plugins>
+        <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-compiler-plugin</artifactId>
+            <configuration>
+                <annotationProcessorPaths>
+                    <!-- Explicit opt-in required via annotationProcessors or
+                            annotationProcessorPaths on Java 22+, see https://bugs.openjdk.org/browse/JDK-8306819 -->
+                    <annotationProcessorPath>
+                        <groupId>io.github.openfeign.querydsl</groupId>
+                        <artifactId>querydsl-apt</artifactId>
+                        <version>${querydslVersion}</version>
+                        <classifier>jpa</classifier>
+                    </annotationProcessorPath>
+                    <annotationProcessorPath>
+                        <groupId>jakarta.persistence</groupId>
+                        <artifactId>jakarta.persistence-api</artifactId>
+                    </annotationProcessorPath>
+                </annotationProcessorPaths>
+                <!-- Recommended: Some IDE's might require this configuration to include generated sources for IDE usage -->
+                <generatedTestSourcesDirectory>target/generated-test-sources</generatedTestSourcesDirectory>
+                <generatedSourcesDirectory>target/generated-sources</generatedSourcesDirectory>
+            </configuration>
+        </plugin>
+    </plugins>
+</build>
+----
+
+Gradle (OpenFeign)::
++
+====
+[source,groovy,indent=0,subs="verbatim,quotes",role="secondary"]
+----
+dependencies {
+
+    implementation "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}"
+    annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:${querydslVersion}:jpa"
+    annotationProcessor 'jakarta.persistence:jakarta.persistence-api'
+
+    testAnnotationProcessor "io.github.openfeign.querydsl:querydsl-apt:${querydslVersion}:jpa"
+    testAnnotationProcessor 'jakarta.persistence:jakarta.persistence-api'
+}
+----
+====
 ======
 
 Note that the setup above shows the simplemost usage omitting any other options or dependencies that your project might require.
diff --git a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc
index c5e113c8f4..0eb4682ff2 100644
--- a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc
+++ b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc
@@ -50,10 +50,10 @@ Consider the following queries:
 ----
 interface UserRepository extends Repository<User, Long> {
 
-  @Query("SELECT u FROM USER u")                                 <1>
+  @Query("SELECT u FROM USER u WHERE u.lastname = :lastname")                       <1>
   List<UserDto> findByLastname(String lastname);
 
-  @Query("SELECT u.firstname, u.lastname FROM USER u")           <2>
+  @Query("SELECT u.firstname, u.lastname FROM USER u WHERE u.lastname = :lastname") <2>
   List<UserDto> findMultipleColumnsByLastname(String lastname);
 }
 
@@ -61,9 +61,9 @@ record UserDto(String firstname, String lastname){}
 ----
 
 <1> Selection of the top-level entity.
-This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM USER u`.
+This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM USER u WHERE u.lastname = :lastname`.
 <2> Multi-select of `firstname` and `lastname` properties.
-This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM USER u`.
+This query gets rewritten to `SELECT new UserDto(u.firstname, u.lastname) FROM USER u WHERE u.lastname = :lastname`.
 ====
 
 [WARNING]
diff --git a/src/main/antora/modules/ROOT/pages/repositories/query-methods-details.adoc b/src/main/antora/modules/ROOT/pages/repositories/query-methods-details.adoc
index dfe4814955..614da0b059 100644
--- a/src/main/antora/modules/ROOT/pages/repositories/query-methods-details.adoc
+++ b/src/main/antora/modules/ROOT/pages/repositories/query-methods-details.adoc
@@ -1 +1,2 @@
+:feature-scroll:
 include::{commons}@data-commons::page$repositories/query-methods-details.adoc[]
diff --git a/src/main/antora/resources/antora-resources/antora.yml b/src/main/antora/resources/antora-resources/antora.yml
index ed14d8c6d8..eedc4999e3 100644
--- a/src/main/antora/resources/antora-resources/antora.yml
+++ b/src/main/antora/resources/antora-resources/antora.yml
@@ -4,6 +4,7 @@ prerelease: ${antora-component.prerelease}
 asciidoc:
   attributes:
     version: ${project.version}
+    copyright-year: ${current.year}
     springversionshort: ${spring.short}
     springversion: ${spring}
     attribute-missing: 'warn'