diff --git a/.github/dco.yml b/.github/dco.yml new file mode 100644 index 0000000000..0c4b142e9a --- /dev/null +++ b/.github/dco.yml @@ -0,0 +1,2 @@ +require: + members: false diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..411d4a9338 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,21 @@ +# GitHub Actions for CodeQL Scanning + +name: "CodeQL Advanced" + +on: + push: + pull_request: + workflow_dispatch: + schedule: + # https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows#schedule + - cron: '0 5 * * *' + +permissions: read-all + +jobs: + codeql-analysis-call: + permissions: + actions: read + contents: read + security-events: write + uses: spring-io/github-actions/.github/workflows/codeql-analysis.yml@1 diff --git a/.github/workflows/project.yml b/.github/workflows/project.yml index 606226523e..4c8108d353 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -10,38 +10,36 @@ on: pull_request_target: types: [opened, edited, reopened] +permissions: + contents: read + issues: write + pull-requests: write + jobs: Inbox: runs-on: ubuntu-latest - if: github.repository_owner == 'spring-projects' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request == null + if: github.repository_owner == 'spring-projects' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request == null && !contains(join(github.event.issue.labels.*.name, ', '), 'dependency-upgrade') && !contains(github.event.issue.title, 'Release ') steps: - name: Create or Update Issue Card - uses: peter-evans/create-or-update-project-card@v1.1.2 + uses: actions/add-to-project@v1.0.2 with: - project-name: 'Spring Data' - column-name: 'Inbox' - project-location: 'spring-projects' - token: ${{ secrets.GH_ISSUES_TOKEN_SPRING_DATA }} + project-url: https://github.com/orgs/spring-projects/projects/25 + github-token: ${{ secrets.GH_ISSUES_TOKEN_SPRING_DATA }} Pull-Request: runs-on: ubuntu-latest if: github.repository_owner == 'spring-projects' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request != null steps: - name: Create or Update Pull Request Card - uses: peter-evans/create-or-update-project-card@v1.1.2 + uses: actions/add-to-project@v1.0.2 with: - project-name: 'Spring Data' - column-name: 'Review pending' - project-location: 'spring-projects' - issue-number: ${{ github.event.pull_request.number }} - token: ${{ secrets.GH_ISSUES_TOKEN_SPRING_DATA }} + project-url: https://github.com/orgs/spring-projects/projects/25 + github-token: ${{ secrets.GH_ISSUES_TOKEN_SPRING_DATA }} Feedback-Provided: runs-on: ubuntu-latest if: github.repository_owner == 'spring-projects' && github.event_name == 'issue_comment' && github.event.action == 'created' && github.actor != 'spring-projects-issues' && github.event.pull_request == null && github.event.issue.state == 'open' && contains(toJSON(github.event.issue.labels), 'waiting-for-feedback') steps: - name: Update Project Card - uses: peter-evans/create-or-update-project-card@v1.1.2 + uses: actions/add-to-project@v1.0.2 with: - project-name: 'Spring Data' - column-name: 'Feedback provided' - project-location: 'spring-projects' - token: ${{ secrets.GH_ISSUES_TOKEN_SPRING_DATA }} + project-url: https://github.com/orgs/spring-projects/projects/25 + github-token: ${{ secrets.GH_ISSUES_TOKEN_SPRING_DATA }} diff --git a/.gitignore b/.gitignore index 6b743d7650..2cca7fefeb 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ target/ .DS_Store node_modules package-lock.json -package.json node build/ -.mvn/.gradle-enterprise +.mvn/.develocity +spring-data-jpa/gen diff --git a/.mvn/extensions.xml b/.mvn/extensions.xml index 85a16c3aa2..e0857eaa25 100644 --- a/.mvn/extensions.xml +++ b/.mvn/extensions.xml @@ -1,13 +1,8 @@ - com.gradle - gradle-enterprise-maven-extension - 1.18.1 + io.spring.develocity.conventions + develocity-conventions-maven-extension + 0.0.22 - - com.gradle - common-custom-user-data-maven-extension - 1.12.2 - - \ No newline at end of file + diff --git a/.mvn/gradle-enterprise.xml b/.mvn/gradle-enterprise.xml deleted file mode 100644 index bbc0073bfe..0000000000 --- a/.mvn/gradle-enterprise.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - https://ge.spring.io - - - false - true - true - - #{{'0.0.0.0'}} - - - - - true - - - - - ${env.DEVELOCITY_CACHE_USERNAME} - ${env.DEVELOCITY_CACHE_PASSWORD} - - - true - #{env['DEVELOCITY_CACHE_USERNAME'] != null and env['DEVELOCITY_CACHE_PASSWORD'] != null} - - - \ No newline at end of file diff --git a/.mvn/jvm.config b/.mvn/jvm.config new file mode 100644 index 0000000000..e27f6e8f5e --- /dev/null +++ b/.mvn/jvm.config @@ -0,0 +1,14 @@ +--add-exports jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED +--add-exports jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED +--add-opens jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED +--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED +--add-opens=java.base/java.util=ALL-UNNAMED +--add-opens=java.base/java.lang.reflect=ALL-UNNAMED +--add-opens=java.base/java.text=ALL-UNNAMED +--add-opens=java.desktop/java.awt.font=ALL-UNNAMED diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 15f4332d38..df07464f75 100755 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,2 +1,2 @@ -#Thu Dec 14 08:40:35 CET 2023 -distributionUrl=https\://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.6/apache-maven-3.9.6-bin.zip +#Thu Jul 17 13:59:51 CEST 2025 +distributionUrl=https\://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/Jenkinsfile b/Jenkinsfile index 6b9bd36e39..2a026b08fa 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -32,16 +32,17 @@ pipeline { options { timeout(time: 30, unit: 'MINUTES') } environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - DEVELOCITY_CACHE = credentials("${p['develocity.cache.credentials']}") DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor' } steps { script { - docker.image(p['docker.java.main.image']).inside(p['docker.java.inside.docker']) { - sh "PROFILE=all-dbs " + - "JENKINS_USER_NAME=${p['jenkins.user.name']} " + - "ci/test.sh" + docker.withRegistry(p['docker.proxy.registry'], p['docker.proxy.credentials']) { + docker.image(p['docker.java.main.image']).inside(p['docker.java.inside.docker']) { + sh "PROFILE=all-dbs " + + "JENKINS_USER_NAME=${p['jenkins.user.name']} " + + "ci/test.sh" + } } } } @@ -57,69 +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_CACHE = credentials("${p['develocity.cache.credentials']}") - DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") - TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor' - } - steps { - script { - 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.5 snapshots)") { - agent { - label 'data' - } - options { timeout(time: 30, unit: 'MINUTES')} - environment { - ARTIFACTORY = credentials("${p['artifactory.credentials']}") - DEVELOCITY_CACHE = credentials("${p['develocity.cache.credentials']}") - DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") - TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor' - } - steps { - script { - docker.image(p['docker.java.next.image']).inside(p['docker.java.inside.docker']) { - sh "PROFILE=all-dbs,hibernate-65-snapshots " + - "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_CACHE = credentials("${p['develocity.cache.credentials']}") - DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") - TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor' - } - steps { - script { - 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' @@ -127,37 +65,17 @@ pipeline { options { timeout(time: 30, unit: 'MINUTES')} environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - DEVELOCITY_CACHE = credentials("${p['develocity.cache.credentials']}") DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor' } steps { script { - docker.image(p['docker.java.next.image']).inside(p['docker.java.inside.docker']) { - sh "PROFILE=all-dbs " + - "JENKINS_USER_NAME=${p['jenkins.user.name']} " + - "ci/test.sh" - } - } - } - } - stage("test: eclipselink-next") { - agent { - label 'data' - } - options { timeout(time: 30, unit: 'MINUTES')} - environment { - ARTIFACTORY = credentials("${p['artifactory.credentials']}") - DEVELOCITY_CACHE = credentials("${p['develocity.cache.credentials']}") - DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") - TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor' - } - steps { - script { - docker.image(p['docker.java.main.image']).inside(p['docker.java.inside.docker']) { - sh "PROFILE=all-dbs,eclipselink-next " + - "JENKINS_USER_NAME=${p['jenkins.user.name']} " + - "ci/test.sh" + 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 " + + "JENKINS_USER_NAME=${p['jenkins.user.name']} " + + "ci/test.sh" + } } } } @@ -179,25 +97,24 @@ pipeline { options { timeout(time: 20, unit: 'MINUTES') } environment { ARTIFACTORY = credentials("${p['artifactory.credentials']}") - DEVELOCITY_CACHE = credentials("${p['develocity.cache.credentials']}") DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") } steps { script { - docker.image(p['docker.java.main.image']).inside(p['docker.java.inside.basic']) { - sh 'MAVEN_OPTS="-Duser.name=' + "${p['jenkins.user.name']}" + ' -Duser.home=/tmp/jenkins-home" ' + - "DEVELOCITY_CACHE_USERNAME=${DEVELOCITY_CACHE_USR} " + - "DEVELOCITY_CACHE_PASSWORD=${DEVELOCITY_CACHE_PSW} " + - "GRADLE_ENTERPRISE_ACCESS_KEY=${DEVELOCITY_ACCESS_KEY} " + - "./mvnw -s settings.xml -Pci,artifactory " + - "-Dartifactory.server=${p['artifactory.url']} " + - "-Dartifactory.username=${ARTIFACTORY_USR} " + - "-Dartifactory.password=${ARTIFACTORY_PSW} " + - "-Dartifactory.staging-repository=${p['artifactory.repository.snapshot']} " + - "-Dartifactory.build-name=spring-data-jpa " + - "-Dartifactory.build-number=spring-data-jpa-${BRANCH_NAME}-build-${BUILD_NUMBER} " + - '-Dmaven.repo.local=/tmp/jenkins-home/.m2/spring-data-jpa-enterprise ' + - '-Dmaven.test.skip=true clean deploy -U -B ' + docker.withRegistry(p['docker.proxy.registry'], p['docker.proxy.credentials']) { + docker.image(p['docker.java.main.image']).inside(p['docker.java.inside.docker']) { + sh 'MAVEN_OPTS="-Duser.name=' + "${p['jenkins.user.name']}" + ' -Duser.home=/tmp/jenkins-home" ' + + "./mvnw -s settings.xml -Pci,artifactory " + + "-Ddevelocity.storage.directory=/tmp/jenkins-home/.develocity-root " + + "-Dartifactory.server=${p['artifactory.url']} " + + "-Dartifactory.username=${ARTIFACTORY_USR} " + + "-Dartifactory.password=${ARTIFACTORY_PSW} " + + "-Dartifactory.staging-repository=${p['artifactory.repository.snapshot']} " + + "-Dartifactory.build-name=spring-data-jpa " + + "-Dartifactory.build-number=spring-data-jpa-${BRANCH_NAME}-build-${BUILD_NUMBER} " + + "-Dmaven.repo.local=/tmp/jenkins-home/.m2/spring-data-jpa " + + "-Dmaven.test.skip=true clean deploy -U -B " + } } } } @@ -207,10 +124,6 @@ pipeline { post { changed { script { - slackSend( - color: (currentBuild.currentResult == 'SUCCESS') ? 'good' : 'danger', - channel: '#spring-data-dev', - message: "${currentBuild.fullDisplayName} - `${currentBuild.currentResult}`\n${env.BUILD_URL}") emailext( subject: "[${currentBuild.fullDisplayName}] ${currentBuild.currentResult}", mimeType: 'text/html', diff --git a/README.adoc b/README.adoc index 6d3eae3271..82e05e71d2 100644 --- a/README.adoc +++ b/README.adoc @@ -1,4 +1,4 @@ -= Spring Data JPA image:https://jenkins.spring.io/buildStatus/icon?job=spring-data-jpa%2Fmain&subject=Build[link=https://jenkins.spring.io/view/SpringData/job/spring-data-jpa/] https://gitter.im/spring-projects/spring-data[image:https://badges.gitter.im/spring-projects/spring-data.svg[Gitter]] image:https://img.shields.io/badge/Revved%20up%20by-Develocity-06A0CE?logo=Gradle&labelColor=02303A["Revved up by Develocity", link="https://ge.spring.io/scans?search.rootProjectNames=Spring Data JPA Parent"] += Spring Data JPA image:https://img.shields.io/badge/Revved%20up%20by-Develocity-06A0CE?logo=Gradle&labelColor=02303A["Revved up by Develocity", link="https://ge.spring.io/scans?search.rootProjectNames=Spring Data JPA Parent"] Spring Data JPA, part of the larger https://projects.spring.io/spring-data[Spring Data] family, makes it easy to implement JPA-based repositories. This module deals with enhanced support for JPA-based data access layers. @@ -55,7 +55,7 @@ public class MyService { repository.save(person); List lastNameResults = repository.findByLastname("Gierke"); - List firstNameResults = repository.findByFirstnameLike("Oli*"); + List firstNameResults = repository.findByFirstnameLike("Oli%"); } } @@ -132,7 +132,6 @@ https://docs.spring.io/spring-data/jpa/reference/[reference documentation], and If you are just starting out with Spring, try one of the https://spring.io/guides[guides]. * If you are upgrading, check out the https://github.com/spring-projects/spring-data-jpa/releases[Spring Data JPA release notes] and scroll down to the one you're considering. See the details there. (Also check out the https://github.com/spring-projects/spring-data-jpa/releases/latest[latest stable release]) * Ask a question - we monitor https://stackoverflow.com[stackoverflow.com] for questions tagged with https://stackoverflow.com/tags/spring-data[`spring-data-jpa`]. -You can also chat with the community on https://gitter.im/spring-projects/spring-data[Gitter]. * Report bugs with Spring Data JPA in the https://github.com/spring-projects/spring-data-jpa/issues[GitHub issue tracker]. == Reporting Issues @@ -144,7 +143,7 @@ https://github.com/spring-projects/spring-data-jpa/issues[issue tracker] to see * If the issue doesn’t exist already, https://github.com/spring-projects/spring-data-jpa/issues[create a new issue]. * Please provide as much information as possible with the issue report, we like to know the version of Spring Data that you are using and JVM version, complete stack traces and any relevant configuration information. * If you need to paste code, or include a stack trace format it as code using triple backtick. -* If possible try to create a test-case or project that replicates the issue. Attach a link to your code or a compressed file containing your code. Use an in-memory datatabase if possible or set the database up using https://github.com/testcontainers[Testcontainers]. +* If possible try to create a test-case or project that replicates the issue. Attach a link to your code or a compressed file containing your code. Use an in-memory database if possible or set the database up using https://github.com/testcontainers[Testcontainers]. == Building from Source @@ -158,7 +157,9 @@ You also need JDK 17 or above. If you want to build with the regular `mvn` command, you will need https://maven.apache.org/run-maven/index.html[Maven v3.8.0 or above]. -_Also see link:CONTRIBUTING.adoc[CONTRIBUTING.adoc] if you wish to submit pull requests, and in particular please sign the https://cla.pivotal.io/sign/spring[Contributor’s Agreement] before your first non-trivial change._ +_Also see link:CONTRIBUTING.adoc[CONTRIBUTING.adoc] if you wish to submit pull requests._ +All commits must include a __Signed-off-by__ trailer at the end of each commit message to indicate that the contributor agrees to the Developer Certificate of Origin. +For additional details, please refer to the blog post https://spring.io/blog/2025/01/06/hello-dco-goodbye-cla-simplifying-contributions-to-spring[Hello DCO, Goodbye CLA: Simplifying Contributions to Spring]. === Building reference documentation @@ -169,7 +170,7 @@ Building the documentation builds also the project without running tests. $ ./mvnw clean install -Pantora ---- -The generated documentation is available from `target/antora/site/index.html`. +The generated documentation is available from `target/antora/index.html`. == Guides diff --git a/ci/pipeline.properties b/ci/pipeline.properties index 60057f2659..ed898052b1 100644 --- a/ci/pipeline.properties +++ b/ci/pipeline.properties @@ -1,22 +1,20 @@ # Java versions -java.main.tag=17.0.9_9-jdk-focal -java.next.tag=21.0.1_12-jdk-jammy +java.main.tag=25-jdk-noble +java.next.tag=25-jdk-noble # Docker container images - standard -docker.java.main.image=harbor-repo.vmware.com/dockerhub-proxy-cache/library/eclipse-temurin:${java.main.tag} -docker.java.next.image=harbor-repo.vmware.com/dockerhub-proxy-cache/library/eclipse-temurin:${java.next.tag} +docker.java.main.image=library/eclipse-temurin:${java.main.tag} +docker.java.next.image=library/eclipse-temurin:${java.next.tag} # Supported versions of MongoDB -docker.mongodb.4.4.version=4.4.25 -docker.mongodb.5.0.version=5.0.21 -docker.mongodb.6.0.version=6.0.10 -docker.mongodb.7.0.version=7.0.2 +docker.mongodb.6.0.version=6.0.23 +docker.mongodb.7.0.version=7.0.20 +docker.mongodb.8.0.version=8.0.9 # Supported versions of Redis docker.redis.6.version=6.2.13 - -# Supported versions of Cassandra -docker.cassandra.3.version=3.11.16 +docker.redis.7.version=7.2.4 +docker.valkey.8.version=8.1.1 # Docker environment settings docker.java.inside.basic=-v $HOME:/tmp/jenkins-home @@ -25,9 +23,10 @@ docker.java.inside.docker=-u root -v /var/run/docker.sock:/var/run/docker.sock - # Credentials docker.registry= docker.credentials=hub.docker.com-springbuildmaster +docker.proxy.registry=https://docker-hub.usw1.packages.broadcom.com +docker.proxy.credentials=usw1_packages_broadcom_com-jenkins-token artifactory.credentials=02bd1690-b54f-4c9f-819d-a77cb7a9822c artifactory.url=https://repo.spring.io artifactory.repository.snapshot=libs-snapshot-local -develocity.cache.credentials=gradle_enterprise_cache_user develocity.access-key=gradle_enterprise_secret_access_key jenkins.user.name=spring-builds+jenkins diff --git a/ci/test.sh b/ci/test.sh index 7e1fecebf0..7be7e11139 100755 --- a/ci/test.sh +++ b/ci/test.sh @@ -3,21 +3,13 @@ set -euo pipefail mkdir -p /tmp/jenkins-home/.m2/spring-data-jpa -mkdir -p /tmp/jenkins-home/.m2/.gradle-enterprise -chown -R 1001:1001 . +mkdir -p /tmp/jenkins-home/.m2/.develocity -export DEVELOCITY_CACHE_USERNAME=${DEVELOCITY_CACHE_USR} -export DEVELOCITY_CACHE_PASSWORD=${DEVELOCITY_CACHE_PSW} export JENKINS_USER=${JENKINS_USER_NAME} -# The environment variable to configure access key is still GRADLE_ENTERPRISE_ACCESS_KEY -export GRADLE_ENTERPRISE_ACCESS_KEY=${DEVELOCITY_ACCESS_KEY} - MAVEN_OPTS="-Duser.name=${JENKINS_USER} -Duser.home=/tmp/jenkins-home" \ ./mvnw -s settings.xml \ - -P${PROFILE} clean dependency:list test -Dsort -U -B -Dmaven.repo.local=/tmp/jenkins-home/.m2/spring-data-jpa + -P${PROFILE} clean dependency:list test -Dsort -U -B -Dmaven.repo.local=/tmp/jenkins-home/.m2/spring-data-jpa -Ddevelocity.storage.directory=/tmp/jenkins-home/.develocity-root MAVEN_OPTS="-Duser.name=${JENKINS_USER} -Duser.home=/tmp/jenkins-home" \ - ./mvnw -s settings.xml clean -Dscan=false -Dmaven.repo.local=/tmp/jenkins-home/.m2/spring-data-jpa - -chown -R 1001:1001 /tmp/jenkins-home/.m2/.gradle-enterprise + ./mvnw -s settings.xml clean -Dscan=false -Dmaven.repo.local=/tmp/jenkins-home/.m2/spring-data-jpa -Ddevelocity.storage.directory=/tmp/jenkins-home/.develocity-root 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 ; + 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; + >> @@ -67,11 +71,15 @@ package ;
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 }. */ +@NullUnmarked +@Generated("Listener") interface Listener extends ParseTreeListener { ;
- 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 Listener}, * which can be extended to create a listener which only needs to handle a subset * of the available methods. */ -@SuppressWarnings("CheckReturnValue") +@NullUnmarked +@Generated("BaseListener") +@SuppressWarnings({ "CheckReturnValue", "NullAway" }) class BaseListener implements Listener { ;
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 \ The return type of the visit operation. Use {@link Void} for * operations with no return type. */ +@NullUnmarked +@Generated("Visitor") interface Visitor\ extends ParseTreeVisitor\ { ;
import org.antlr.v4.runtime.tree.AbstractParseTreeVisitor; +import org.jspecify.annotations.NullUnmarked; +import jakarta.annotation.Generated; /** * This class provides an empty implementation of {@link Visitor}, @@ -203,7 +220,9 @@ import org.antlr.v4.runtime.tree.AbstractParseTreeVisitor; * @param \ The return type of the visit operation. Use {@link Void} for * operations with no return type. */ -@SuppressWarnings("CheckReturnValue") +@NullUnmarked +@Generated("BaseVisitor") +@SuppressWarnings({ "CheckReturnValue", "NullAway" }) class BaseVisitor\ extends AbstractParseTreeVisitor\ implements Visitor\ { > Parser_(parser, funcs, atn, sempredFuncs, ctor, superClass) ::= << -@SuppressWarnings({"all", "warnings", "unchecked", "unused", "cast", "CheckReturnValue"}) +@NullUnmarked +@Generated("") +@SuppressWarnings({"all", "warnings", "unchecked", "unused", "cast", "CheckReturnValue", "NullAway"}) class extends { // Customization: Suppress version check // static { RuntimeMetaData.checkVersion("", 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, atn, actionFuncs, sempredFuncs, superClass) ::= << -@SuppressWarnings({"all", "warnings", "unchecked", "unused", "cast", "CheckReturnValue"}) +@NullUnmarked +@Generated("") +@SuppressWarnings({"all", "warnings", "unchecked", "unused", "cast", "CheckReturnValue", "NullAway"}) class extends { // Customization: Suppress version check // static { RuntimeMetaData.checkVersion("", RuntimeMetaData.VERSION); } diff --git a/pom.xml b/pom.xml index db48010c64..18c3228530 100755 --- a/pom.xml +++ b/pom.xml @@ -1,11 +1,11 @@ - + 4.0.0 org.springframework.data spring-data-jpa-parent - 3.3.0 + 4.0.0-SNAPSHOT pom Spring Data JPA Parent @@ -23,31 +23,28 @@ org.springframework.data.build spring-data-parent - 3.3.0 + 4.0.0-SNAPSHOT - 4.13.0 - 3.0.4 - 4.0.2 - 6.5.0.Final - 6.2.24.Final - 6.5.1-SNAPSHOT - 6.6.0-SNAPSHOT - 7.0.0-SNAPSHOT - 2.7.1 -

2.2.220

- 3.1.0 - 4.9 - 8.0.33 - 42.6.0 - 3.3.0 + 4.13.2 + 5.0.0-B11 + 5.0.0-SNAPSHOT + 7.1.4.Final + 7.1.2-SNAPSHOT + 2.7.4 +

2.3.232

+ 3.2.0 + 5.3 + 9.2.0 + 42.7.7 + 23.8.0.25.04 + 4.0.0-SNAPSHOT 0.10.3 org.hibernate reuseReports -
@@ -59,46 +56,19 @@ - hibernate-62 - - ${hibernate-62} - - - - hibernate-65-snapshots - - ${hibernate-65-snapshots} - + jmh - sonatype-oss - https://oss.sonatype.org/content/repositories/snapshots - - false - + jitpack + https://jitpack.io - hibernate-66-snapshots + hibernate-71-snapshots - ${hibernate-66-snapshots} - - - - sonatype-oss - https://oss.sonatype.org/content/repositories/snapshots - - false - - - - - - hibernate-70-snapshots - - ${hibernate-70-snapshots} - 3.2.0-M2 + ${hibernate-71-snapshots} + 3.2.0 @@ -138,7 +108,21 @@ - **/Postgres*IntegrationTests.java + **/Postgres*IntegrationTests.java + + + + + + oracle-test + test + + test + + + + **/Oracle*IntegrationTests.java + @@ -152,6 +136,16 @@ ${eclipselink-next} + + + jakarta.oss.sonatype.org + Jakarta OSS Sonatype Staging + https://jakarta.oss.sonatype.org/content/repositories/staging + + false + + + @@ -177,92 +171,13 @@ - - - - org.apache.maven.plugins - maven-surefire-plugin - - - org.springframework - spring-instrument - ${spring} - runtime - - - - - - default-test - - - **/* - - - - - unit-test - - test - - test - - - **/*UnitTests.java - - - - - integration-test - - test - - test - - - **/*IntegrationTests.java - **/*Tests.java - - - **/*UnitTests.java - **/OpenJpa* - **/EclipseLink* - **/MySql* - **/Postgres* - - - -javaagent:${settings.localRepository}/org/springframework/spring-instrument/${spring}/spring-instrument-${spring}.jar - - - - - eclipselink-test - - test - - test - - - **/EclipseLink*Tests.java - - - -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 - - - - - - - - com.gradle - gradle-enterprise-maven-extension + develocity-maven-extension - + @@ -270,7 +185,7 @@ - + @@ -278,8 +193,20 @@ - - + + spring-snapshot + https://repo.spring.io/snapshot + + true + + + false + + + + spring-milestone + https://repo.spring.io/milestone + diff --git a/spring-data-envers/pom.xml b/spring-data-envers/pom.xml index 11d29dee41..0bdf2c8e7e 100755 --- a/spring-data-envers/pom.xml +++ b/spring-data-envers/pom.xml @@ -5,12 +5,12 @@ org.springframework.data spring-data-envers - 3.3.0 + 4.0.0-SNAPSHOT org.springframework.data spring-data-jpa-parent - 3.3.0 + 4.0.0-SNAPSHOT ../pom.xml diff --git a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/config/EnableEnversRepositories.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/config/EnableEnversRepositories.java index ab3c0ff948..feb8782229 100644 --- a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/config/EnableEnversRepositories.java +++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/config/EnableEnversRepositories.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. @@ -24,6 +24,7 @@ import java.lang.annotation.Target; import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.support.BeanNameGenerator; import org.springframework.context.annotation.ComponentScan.Filter; import org.springframework.context.annotation.Lazy; import org.springframework.core.annotation.AliasFor; @@ -136,6 +137,15 @@ @AliasFor(annotation = EnableJpaRepositories.class) Class repositoryBaseClass() default DefaultRepositoryBaseClass.class; + /** + * Configure a specific {@link BeanNameGenerator} to be used when creating the repositoy beans. + * @return the {@link BeanNameGenerator} to be used or the base {@link BeanNameGenerator} interface to indicate context default. + * @since 3.4 + * @see EnableJpaRepositories#nameGenerator() + */ + @AliasFor(annotation = EnableJpaRepositories.class) + Class nameGenerator() default BeanNameGenerator.class; + // JPA specific configuration /** 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/DefaultRevisionEntityInformation.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/DefaultRevisionEntityInformation.java index cf461e8d9f..565b84dc45 100644 --- a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/DefaultRevisionEntityInformation.java +++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/DefaultRevisionEntityInformation.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -22,18 +22,29 @@ * {@link RevisionEntityInformation} for {@link DefaultRevisionEntity}. * * @author Oliver Gierke + * @author Chaedong Im */ -class DefaultRevisionEntityInformation implements RevisionEntityInformation { +enum DefaultRevisionEntityInformation implements EnversRevisionEntityInformation { + INSTANCE; + + @Override public Class getRevisionNumberType() { return Integer.class; } + @Override public boolean isDefaultRevisionEntity() { return true; } + @Override public Class getRevisionEntityClass() { return DefaultRevisionEntity.class; } + + @Override + public String getRevisionTimestampPropertyName() { + return "timestamp"; + } } diff --git a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/DefaultRevisionMetadata.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/DefaultRevisionMetadata.java index 6b92607b1f..5c7672d10d 100755 --- a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/DefaultRevisionMetadata.java +++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/DefaultRevisionMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionEntityInformation.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionEntityInformation.java new file mode 100644 index 0000000000..a7149f3205 --- /dev/null +++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionEntityInformation.java @@ -0,0 +1,35 @@ +/* + * 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.envers.repository.support; + +import org.springframework.data.repository.history.support.RevisionEntityInformation; + +/** + * Envers-specific extension to {@link RevisionEntityInformation}. + * + * @author Mark Paluch + * @since 4.0 + */ +public interface EnversRevisionEntityInformation extends RevisionEntityInformation { + + /** + * Return the name of the timestamp property (annotated with {@link org.hibernate.envers.RevisionTimestamp}). + * + * @return the name of the timestamp property, + */ + String getRevisionTimestampPropertyName(); + +} diff --git a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepository.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepository.java index 2d0ba8bea5..f1483304f3 100644 --- a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepository.java +++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. 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 dd7a6b4768..decbcf3f3a 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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,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, S, ID, N extends Number & Comparable> extends JpaRepositoryFactoryBean { - 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 revisionEntityClass) { + public RevisionRepositoryFactory(EntityManager entityManager, @Nullable Class revisionEntityClass) { super(entityManager); @@ -88,13 +90,13 @@ public RevisionRepositoryFactory(EntityManager entityManager, Class revisionE this.revisionEntityInformation = Optional.ofNullable(revisionEntityClass) // .filter(it -> !it.equals(DefaultRevisionEntity.class))// . map(ReflectionRevisionEntityInformation::new) // - .orElseGet(DefaultRevisionEntityInformation::new); + .orElse(DefaultRevisionEntityInformation.INSTANCE); } @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/EnversRevisionRepositoryImpl.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryImpl.java index ed620c15a3..a724d86f9f 100755 --- a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryImpl.java +++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -64,12 +64,16 @@ * @author Niklas Loechte * @author Donghun Shin * @author Greg Turnquist + * @author Aref Behboodi + * @author Ngoc Nhan + * @author Chaedong Im */ @Transactional(readOnly = true) public class EnversRevisionRepositoryImpl> implements RevisionRepository { private final EntityInformation entityInformation; + private final RevisionEntityInformation revisionEntityInformation; private final EntityManager entityManager; /** @@ -83,17 +87,23 @@ public class EnversRevisionRepositoryImpl entityInformation, RevisionEntityInformation revisionEntityInformation, EntityManager entityManager) { + Assert.notNull(entityInformation, "JpaEntityInformation must not be null!"); + Assert.notNull(entityManager, "EntityManager must not be null!"); Assert.notNull(revisionEntityInformation, "RevisionEntityInformation must not be null!"); this.entityInformation = entityInformation; + this.revisionEntityInformation = revisionEntityInformation; this.entityManager = entityManager; } + @Override @SuppressWarnings("unchecked") public Optional> findLastChangeRevision(ID id) { + String timestampFieldName = getRevisionTimestampFieldName(); List singleResult = createBaseQuery(id) // - .addOrder(AuditEntity.revisionProperty("timestamp").desc()) // + .addOrder(AuditEntity.revisionProperty(timestampFieldName).desc()) // + .addOrder(AuditEntity.revisionNumber().desc()) // .setMaxResults(1) // .getResultList(); @@ -113,7 +123,7 @@ public Optional> findRevision(ID id, N revisionNumber) { Assert.notNull(id, "Identifier must not be null!"); Assert.notNull(revisionNumber, "Revision number must not be null!"); - List singleResult = (List) createBaseQuery(id) // + List singleResult = createBaseQuery(id) // .add(AuditEntity.revisionNumber().eq(revisionNumber)) // .getResultList(); @@ -126,6 +136,7 @@ public Optional> findRevision(ID id, N revisionNumber) { return Optional.of(createRevision(new QueryResult<>(singleResult.get(0)))); } + @Override @SuppressWarnings("unchecked") public Revisions findRevisions(ID id) { @@ -166,20 +177,24 @@ private List mapPropertySort(Sort sort) { return result; } + @Override @SuppressWarnings("unchecked") public Page> findRevisions(ID id, Pageable pageable) { AuditQuery baseQuery = createBaseQuery(id); - List orderMapped = (pageable.getSort()instanceof RevisionSort revisionSort) + List orderMapped = (pageable.getSort() instanceof RevisionSort revisionSort) ? List.of(mapRevisionSort(revisionSort)) : mapPropertySort(pageable.getSort()); orderMapped.forEach(baseQuery::addOrder); + if (pageable.isPaged()) { + baseQuery.setFirstResult((int) pageable.getOffset()) // + .setMaxResults(pageable.getPageSize()); + } + List resultList = baseQuery // - .setFirstResult((int) pageable.getOffset()) // - .setMaxResults(pageable.getPageSize()) // .getResultList(); Long count = (Long) createBaseQuery(id) // @@ -190,7 +205,6 @@ public Page> findRevisions(ID id, Pageable pageable) { for (Object[] singleResult : resultList) { revisions.add(createRevision(new QueryResult<>(singleResult))); } - return new PageImpl<>(revisions, pageable, count); } @@ -209,6 +223,14 @@ private Revision createRevision(QueryResult queryResult) { return Revision.of((RevisionMetadata) queryResult.createRevisionMetadata(), queryResult.entity); } + private String getRevisionTimestampFieldName() { + if (revisionEntityInformation instanceof EnversRevisionEntityInformation reflection) { + return reflection.getRevisionTimestampPropertyName(); + } else { + return DefaultRevisionEntityInformation.INSTANCE.getRevisionTimestampPropertyName(); + } + } + @SuppressWarnings("unchecked") static class QueryResult { diff --git a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/ReflectionRevisionEntityInformation.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/ReflectionRevisionEntityInformation.java index ea629fcdbe..7022e33b28 100644 --- a/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/ReflectionRevisionEntityInformation.java +++ b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/ReflectionRevisionEntityInformation.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -16,6 +16,7 @@ package org.springframework.data.envers.repository.support; import org.hibernate.envers.RevisionNumber; +import org.hibernate.envers.RevisionTimestamp; import org.springframework.data.repository.history.support.RevisionEntityInformation; import org.springframework.data.util.AnnotationDetectionFieldCallback; @@ -27,11 +28,13 @@ * find out about the revision number type. * * @author Oliver Gierke + * @author Chaedong Im */ -public class ReflectionRevisionEntityInformation implements RevisionEntityInformation { +public class ReflectionRevisionEntityInformation implements EnversRevisionEntityInformation { private final Class revisionEntityClass; private final Class revisionNumberType; + private final String revisionTimestampFieldName; /** * Creates a new {@link ReflectionRevisionEntityInformation} inspecting the given revision entity class. @@ -42,23 +45,34 @@ public ReflectionRevisionEntityInformation(Class revisionEntityClass) { Assert.notNull(revisionEntityClass, "Revision entity type must not be null"); - AnnotationDetectionFieldCallback fieldCallback = new AnnotationDetectionFieldCallback(RevisionNumber.class); - ReflectionUtils.doWithFields(revisionEntityClass, fieldCallback); + AnnotationDetectionFieldCallback revisionNumberFieldCallback = new AnnotationDetectionFieldCallback(RevisionNumber.class); + ReflectionUtils.doWithFields(revisionEntityClass, revisionNumberFieldCallback); - this.revisionNumberType = fieldCallback.getRequiredType(); - this.revisionEntityClass = revisionEntityClass; + AnnotationDetectionFieldCallback revisionTimestampFieldCallback = new AnnotationDetectionFieldCallback(RevisionTimestamp.class); + ReflectionUtils.doWithFields(revisionEntityClass, revisionTimestampFieldCallback); + this.revisionNumberType = revisionNumberFieldCallback.getRequiredType(); + this.revisionTimestampFieldName = revisionTimestampFieldCallback.getRequiredField().getName(); + this.revisionEntityClass = revisionEntityClass; } + @Override public boolean isDefaultRevisionEntity() { return false; } + @Override public Class getRevisionEntityClass() { return this.revisionEntityClass; } + @Override public Class getRevisionNumberType() { return this.revisionNumberType; } + + @Override + public String getRevisionTimestampPropertyName() { + return this.revisionTimestampFieldName; + } } 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-envers/src/test/java/org/springframework/data/envers/Config.java b/spring-data-envers/src/test/java/org/springframework/data/envers/Config.java index e6fb10fc29..be69b3251b 100755 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/Config.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/Config.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -21,7 +21,7 @@ import javax.sql.DataSource; -import org.hibernate.envers.strategy.ValidityAuditStrategy; +import org.hibernate.envers.strategy.internal.ValidityAuditStrategy; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -31,7 +31,6 @@ import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean; import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; -import org.springframework.orm.jpa.vendor.Database; import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; import org.springframework.transaction.PlatformTransactionManager; @@ -58,7 +57,6 @@ public PlatformTransactionManager transactionManager() throws SQLException { public AbstractEntityManagerFactoryBean entityManagerFactory() throws SQLException { HibernateJpaVendorAdapter jpaVendorAdapter = new HibernateJpaVendorAdapter(); - jpaVendorAdapter.setDatabase(Database.H2); jpaVendorAdapter.setGenerateDdl(true); LocalContainerEntityManagerFactoryBean bean = new LocalContainerEntityManagerFactoryBean(); diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/DefaultRevisionEntityInformationUnitTests.java b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/DefaultRevisionEntityInformationUnitTests.java new file mode 100644 index 0000000000..5d6d49e075 --- /dev/null +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/DefaultRevisionEntityInformationUnitTests.java @@ -0,0 +1,38 @@ +/* + * 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.envers.repository.support; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link DefaultRevisionEntityInformation}. + * + * @author Mark Paluch + * @author Chaedong Im + */ +class DefaultRevisionEntityInformationUnitTests { + + @Test // GH-2850 + void defaultRevisionEntityInformationReturnsStandardTimestampFieldName() { + + DefaultRevisionEntityInformation revisionInfo = DefaultRevisionEntityInformation.INSTANCE; + + assertThat(revisionInfo.getRevisionTimestampPropertyName()).isEqualTo("timestamp"); + } + +} diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/DefaultRevisionMetadataUnitTests.java b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/DefaultRevisionMetadataUnitTests.java index fe99891683..f5e50b0dcc 100644 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/DefaultRevisionMetadataUnitTests.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/DefaultRevisionMetadataUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -34,7 +34,7 @@ */ class DefaultRevisionMetadataUnitTests { - private static final Instant NOW = Instant.now();; + private static final Instant NOW = Instant.now(); @Test // #112 void createsLocalDateTimeFromTimestamp() { diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryImplUnitTests.java b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryImplUnitTests.java index 81db782d28..78c229910b 100644 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryImplUnitTests.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryImplUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-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. @@ -21,6 +21,7 @@ import org.hibernate.envers.DefaultRevisionEntity; import org.hibernate.envers.RevisionType; import org.junit.jupiter.api.Test; + import org.springframework.data.history.AnnotationRevisionMetadata; import org.springframework.data.history.RevisionMetadata; diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/QueryDslRepositoryIntegrationTests.java b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/QueryDslRepositoryIntegrationTests.java index 7aed270c92..7009f219e5 100755 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/QueryDslRepositoryIntegrationTests.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/QueryDslRepositoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/ReflectionRevisionEntityInformationUnitTests.java b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/ReflectionRevisionEntityInformationUnitTests.java new file mode 100644 index 0000000000..94d173742a --- /dev/null +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/ReflectionRevisionEntityInformationUnitTests.java @@ -0,0 +1,64 @@ +/* + * 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.envers.repository.support; + +import static org.assertj.core.api.Assertions.*; + +import org.hibernate.envers.RevisionNumber; +import org.hibernate.envers.RevisionTimestamp; +import org.junit.jupiter.api.Test; + +import org.springframework.data.envers.sample.CustomRevisionEntity; + +/** + * Unit tests for {@link ReflectionRevisionEntityInformation}. + * + * @author Mark Paluch + * @author Chaedong Im + */ +class ReflectionRevisionEntityInformationUnitTests { + + @Test // GH-2850 + void reflectionRevisionEntityInformationDetectsStandardTimestampField() { + + ReflectionRevisionEntityInformation revisionInfo = new ReflectionRevisionEntityInformation( + CustomRevisionEntity.class); + + assertThat(revisionInfo.getRevisionTimestampPropertyName()).isEqualTo("timestamp"); + } + + @Test // GH-2850 + void reflectionRevisionEntityInformationDetectsCustomTimestampField() { + + ReflectionRevisionEntityInformation revisionInfo = new ReflectionRevisionEntityInformation( + WithCustomTimestampPropertyName.class); + + assertThat(revisionInfo.getRevisionTimestampPropertyName()).isEqualTo("myCustomTimestamp"); + } + + /** + * Custom revision entity with a non-standard timestamp field name to test dynamic timestamp property detection. + * + * @author Chaedong Im + */ + private static class WithCustomTimestampPropertyName { + + @RevisionNumber private int revisionId; + + @RevisionTimestamp private long myCustomTimestamp; // Non-standard field name + } + +} diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/RepositoryIntegrationTests.java b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/RepositoryIntegrationTests.java index 1a4ce785ea..bf04d06e28 100755 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/RepositoryIntegrationTests.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/RepositoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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,23 @@ */ package org.springframework.data.envers.repository.support; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.history.RevisionMetadata.RevisionType.*; + +import java.time.Instant; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Optional; + 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.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.envers.Config; import org.springframework.data.envers.sample.Country; @@ -33,21 +44,13 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; -import java.time.Instant; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Iterator; -import java.util.Optional; - -import static org.assertj.core.api.Assertions.*; -import static org.springframework.data.history.RevisionMetadata.RevisionType.*; - /** * Integration tests for repositories. * * @author Oliver Gierke * @author Jens Schauder * @author Niklas Loechte + * @author Mark Paluch */ @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = Config.class) @@ -107,6 +110,40 @@ void testLifeCycle() { }); } + @Test // GH-3999 + void shouldReturnUnpagedResults() { + + License license = new License(); + license.name = "Schnitzel"; + + licenseRepository.save(license); + + Country de = new Country(); + de.code = "de"; + de.name = "Deutschland"; + + countryRepository.save(de); + + Country se = new Country(); + se.code = "se"; + se.name = "Schweden"; + + countryRepository.save(se); + + license.laender = new HashSet<>(); + license.laender.addAll(Arrays.asList(de, se)); + + licenseRepository.save(license); + + de.name = "Daenemark"; + + countryRepository.save(de); + + Page> revisions = licenseRepository.findRevisions(license.id, Pageable.unpaged()); + + assertThat(revisions).hasSize(2); + } + @Test // #1 void returnsEmptyLastRevisionForUnrevisionedEntity() { assertThat(countryRepository.findLastChangeRevision(100L)).isEmpty(); diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/AbstractEntity.java b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/AbstractEntity.java index 29cc905a15..1120fded1b 100644 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/AbstractEntity.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/AbstractEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/Country.java b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/Country.java index 67a2fed9d4..5973ecaa78 100755 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/Country.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/Country.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CountryQueryDslRepository.java b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CountryQueryDslRepository.java index 741b2c117f..b7bc3e1c5a 100755 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CountryQueryDslRepository.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CountryQueryDslRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CountryRepository.java b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CountryRepository.java index 046de77f23..4f5cc1f2f5 100755 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CountryRepository.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CountryRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CustomRevisionEntity.java b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CustomRevisionEntity.java index be2dfd0fe3..d69410894d 100644 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CustomRevisionEntity.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CustomRevisionEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CustomRevisionListener.java b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CustomRevisionListener.java index 3bda04361d..80b45588db 100644 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CustomRevisionListener.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/CustomRevisionListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/License.java b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/License.java index b6542eaa48..9ba1070f0a 100755 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/License.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/License.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/LicenseRepository.java b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/LicenseRepository.java index 87b38d5b88..2d6b7737df 100755 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/LicenseRepository.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/LicenseRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/QCountry.java b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/QCountry.java index c7269cab3d..4d1f03c430 100755 --- a/spring-data-envers/src/test/java/org/springframework/data/envers/sample/QCountry.java +++ b/spring-data-envers/src/test/java/org/springframework/data/envers/sample/QCountry.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. @@ -23,6 +23,8 @@ import static com.querydsl.core.types.PathMetadataFactory.forVariable; +import java.io.Serial; + /** * Query class for Country domain. * @@ -30,7 +32,7 @@ */ public class QCountry extends EntityPathBase { - private static final long serialVersionUID = -936338527; + @Serial private static final long serialVersionUID = -936338527; private static final PathInits INITS = PathInits.DIRECT2; diff --git a/spring-data-jpa-distribution/package.json b/spring-data-jpa-distribution/package.json new file mode 100644 index 0000000000..057a40fe8b --- /dev/null +++ b/spring-data-jpa-distribution/package.json @@ -0,0 +1,10 @@ +{ + "dependencies": { + "antora": "3.2.0-alpha.6", + "@antora/atlas-extension": "1.0.0-alpha.2", + "@antora/collector-extension": "1.0.0-alpha.7", + "@asciidoctor/tabs": "1.0.0-beta.6", + "@springio/antora-extensions": "1.13.0", + "@springio/asciidoctor-extensions": "1.0.0-alpha.11" + } +} diff --git a/spring-data-jpa-distribution/pom.xml b/spring-data-jpa-distribution/pom.xml index e2386943ce..af5244a230 100644 --- a/spring-data-jpa-distribution/pom.xml +++ b/spring-data-jpa-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-jpa-parent - 3.3.0 + 4.0.0-SNAPSHOT ../pom.xml @@ -51,7 +51,7 @@ - io.spring.maven.antora + org.antora antora-maven-plugin diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml index ef7162f6a8..cbec8a2645 100644 --- a/spring-data-jpa/pom.xml +++ b/spring-data-jpa/pom.xml @@ -1,12 +1,13 @@ - + 4.0.0 org.springframework.data spring-data-jpa - 3.3.0 + 4.0.0-SNAPSHOT Spring Data JPA Spring Data module for JPA repositories. @@ -15,7 +16,7 @@ org.springframework.data spring-data-jpa-parent - 3.3.0 + 4.0.0-SNAPSHOT ../pom.xml @@ -46,6 +47,13 @@ spring-aop + + org.aspectj + aspectjrt + ${aspectj} + true + + org.springframework spring-tx @@ -65,12 +73,6 @@ org.springframework spring-core - - - commons-logging - commons-logging - - @@ -86,6 +88,44 @@ true + + org.springframework + spring-test + test + + + + org.junit.platform + junit-platform-launcher + test + + + + org.junit-pioneer + junit-pioneer + ${junit-pioneer} + test + + + + org.springframework + spring-core-test + test + + + + net.javacrumbs.json-unit + json-unit-assertj + 4.1.0 + test + + + + com.fasterxml.jackson.core + jackson-databind + test + + org.hsqldb hsqldb @@ -134,6 +174,28 @@ test + + + + com.oracle.database.jdbc + ojdbc17 + ${oracle} + test + + + + com.oracle.database.jdbc + ucp17 + ${oracle} + test + + + + org.testcontainers + oracle-free + test + + io.vavr vavr @@ -156,6 +218,20 @@ + + org.jboss.logging + jboss-logging + 3.6.1.Final + provided + + + + ${hibernate.groupId}.orm + hibernate-vector + ${hibernate} + true + + ${hibernate.groupId}.orm hibernate-jpamodelgen @@ -282,12 +358,13 @@ **/*UnitTests.java - **/OpenJpa* **/EclipseLink* **/MySql* **/Postgres* + **/Oracle* + -Xmx4G -javaagent:${settings.localRepository}/org/springframework/spring-instrument/${spring}/spring-instrument-${spring}.jar @@ -303,6 +380,7 @@ **/EclipseLink*Tests.java + -Xmx4G -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 @@ -323,7 +401,8 @@ generate-sources true - ${project.basedir}/src/main/antlr4 + ${project.basedir}/src/main/antlr4 + @@ -333,7 +412,7 @@ org.apache.maven.plugins maven-compiler-plugin - + com.querydsl querydsl-apt @@ -345,11 +424,26 @@ hibernate-jpamodelgen ${hibernate} + + org.hibernate.orm + hibernate-core + ${hibernate} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh} + jakarta.persistence jakarta.persistence-api ${jakarta-persistence-api} + + org.jboss.logging + jboss-logging + 3.6.1.Final + 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..e2dd2d3107 --- /dev/null +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/AotRepositoryQueryMethodBenchmarks.java @@ -0,0 +1,279 @@ +/* + * 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.mockito.Mockito; +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.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.DefaultBeanNameGenerator; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.core.type.AnnotationMetadata; +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.config.EnableJpaRepositories; +import org.springframework.data.jpa.repository.sample.SampleConfig; +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.config.AnnotationRepositoryConfigurationSource; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +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 repositoryContext; + + static { + RepositoryConfigurationSource configurationSource = new AnnotationRepositoryConfigurationSource( + AnnotationMetadata.introspect(SampleConfig.class), EnableJpaRepositories.class, new DefaultResourceLoader(), + new StandardEnvironment(), Mockito.mock(BeanDefinitionRegistry.class), DefaultBeanNameGenerator.INSTANCE); + + repositoryContext = new TestJpaAotRepositoryContext<>(new DefaultListableBeanFactory(), PersonRepository.class, + null, configurationSource); + } + + 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 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 derivedFinderMethod(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllByFirstname(PERSON_FIRSTNAME); + } + + /*@Benchmark + public List derivedFinderMethodWithInterfaceProjection(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllAndProjectToInterfaceByFirstname(PERSON_FIRSTNAME); + } */ + + @Benchmark + public List stringBasedQuery(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME); + } + + @Benchmark + public List stringBasedQueryDynamicSort(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME, + Sort.by(COLUMN_PERSON_FIRSTNAME)); + } + + @Benchmark + public List 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/RepositoryQueryMethodBenchmarks.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryQueryMethodBenchmarks.java new file mode 100644 index 0000000000..0f20652d65 --- /dev/null +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryQueryMethodBenchmarks.java @@ -0,0 +1,219 @@ +/* + * 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 jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; + +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.data.domain.Sort; +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; +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 RepositoryQueryMethodBenchmarks { + + private static final String PERSON_FIRSTNAME = "first"; + private static final String COLUMN_PERSON_FIRSTNAME = "firstname"; + + @State(Scope.Benchmark) + public static class BenchmarkParameters { + + EntityManager entityManager; + 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(); + } + } + + this.repositoryProxy = createRepository(); + } + + @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() { + 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 + public PersonRepository repositoryBootstrap(BenchmarkParameters parameters) { + return parameters.createRepository(); + } + + @Benchmark + public List baselineEntityManagerCriteriaQuery(BenchmarkParameters parameters) { + + CriteriaBuilder criteriaBuilder = parameters.entityManager.getCriteriaBuilder(); + CriteriaQuery query = criteriaBuilder.createQuery(Person.class); + Root root = query.from(Person.class); + TypedQuery typedQuery = parameters.entityManager + .createQuery(query.where(criteriaBuilder.equal(root.get(COLUMN_PERSON_FIRSTNAME), PERSON_FIRSTNAME))); + + return typedQuery.getResultList(); + } + + @Benchmark + public List 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 derivedFinderMethod(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllByFirstname(PERSON_FIRSTNAME); + } + + /*@Benchmark + public List derivedFinderMethodWithInterfaceProjection(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllAndProjectToInterfaceByFirstname(PERSON_FIRSTNAME); + } */ + + @Benchmark + public List stringBasedQuery(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME); + } + + @Benchmark + public List stringBasedQueryDynamicSort(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME, + Sort.by(COLUMN_PERSON_FIRSTNAME)); + } + + @Benchmark + public List stringBasedQueryDynamicSortAndProjection(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME, + Sort.by(COLUMN_PERSON_FIRSTNAME), PersonDto.class); + } + + @Benchmark + public List 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/test/java/org/springframework/data/jpa/repository/query/OpenJpaJpa21UtilsTests.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/IPersonProjection.java similarity index 68% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaJpa21UtilsTests.java rename to spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/IPersonProjection.java index b8299fa9a1..442385ca32 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaJpa21UtilsTests.java +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/IPersonProjection.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 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. @@ -13,14 +13,13 @@ * 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 Christoph Strobl */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaJpa21UtilsTests extends Jpa21UtilsTests { +public interface IPersonProjection { + String getFirstname(); + String getLastname(); } diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/Person.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/Person.java new file mode 100644 index 0000000000..aa830371aa --- /dev/null +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/Person.java @@ -0,0 +1,119 @@ +/* + * 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.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.Table; + +import java.util.Set; + +/** + * @author Christoph Strobl + */ +@Entity +@Table(name = "person") +public class Person { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) // + private Integer id; + + private String firstname; + private String lastname; + + private int age; + private boolean decased; + + @Column(nullable = false, unique = true) // + private String emailAddress; + + @ManyToMany // + private Set profiles; + + public Person() {} + + public Person(String firstname, String lastname) { + this(firstname, lastname, "%s.%s@benchmark.com"); + } + + public Person(String firstname, String lastname, String emailAddress) { + + this.firstname = firstname; + this.lastname = lastname; + this.emailAddress = emailAddress; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getEmailAddress() { + return emailAddress; + } + + public void setEmailAddress(String emailAddress) { + this.emailAddress = emailAddress; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + + public boolean isDecased() { + return decased; + } + + public void setDecased(boolean decased) { + this.decased = decased; + } + + public Set getProfiles() { + return profiles; + } + + public void setProfiles(Set profiles) { + this.profiles = profiles; + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaEntityGraphRepositoryMethodsIntegrationTests.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/PersonDto.java similarity index 62% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaEntityGraphRepositoryMethodsIntegrationTests.java rename to spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/PersonDto.java index 22390b7682..6241e6a439 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaEntityGraphRepositoryMethodsIntegrationTests.java +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/PersonDto.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 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,12 +13,10 @@ * 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; +package org.springframework.data.jpa.benchmark.model; /** - * @author Oliver Gierke + * @author Mark Paluch */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaEntityGraphRepositoryMethodsIntegrationTests extends EntityGraphRepositoryMethodsIntegrationTests {} +public record PersonDto(String firstname, String lastname) { +} diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/Profile.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/Profile.java new file mode 100644 index 0000000000..acd3f39b97 --- /dev/null +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/Profile.java @@ -0,0 +1,53 @@ +/* + * 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.model; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; + +/** + * @author Christoph Strobl + */ +@Entity +public class Profile { + + @Id + @GeneratedValue private Integer id; + private String name; + + public Profile() {} + + public Profile(String name) { + this.name = name; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} 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 new file mode 100644 index 0000000000..81950ab3fa --- /dev/null +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/repository/PersonRepository.java @@ -0,0 +1,51 @@ +/* + * 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.repository; + +import java.util.List; + +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.repository.Query; +import org.springframework.data.repository.ListCrudRepository; + +/** + * @author Christoph Strobl + */ +public interface PersonRepository extends ListCrudRepository { + + List findAllByFirstname(String firstname); + + List findAllAndProjectToInterfaceByFirstname(String firstname); + + @Query("SELECT p FROM org.springframework.data.jpa.benchmark.model.Person p WHERE p.firstname = ?1") + List findAllWithAnnotatedQueryByFirstname(String firstname); + + @Query("SELECT p FROM org.springframework.data.jpa.benchmark.model.Person p WHERE p.firstname = ?1") + List findAllWithAnnotatedQueryByFirstname(String firstname, Sort sort); + + @Query("SELECT p FROM org.springframework.data.jpa.benchmark.model.Person p WHERE p.firstname = ?1") + List findAllWithAnnotatedQueryByFirstname(String firstname, Sort sort, Class projection); + + @Query(value = "SELECT * FROM person WHERE firstname = ?1", nativeQuery = true) + List findAllWithNativeQueryByFirstname(String firstname); + + Long countByFirstname(String firstname); + + @Query("SELECT COUNT(*) FROM org.springframework.data.jpa.benchmark.model.Person p WHERE p.firstname = ?1") + Long countWithAnnotatedQueryByFirstname(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 new file mode 100644 index 0000000000..d1465ed1bc --- /dev/null +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/HqlParserBenchmarks.java @@ -0,0 +1,73 @@ +/* + * 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 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.Timeout; +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 + */ +@Testable +@Fork(1) +@Warmup(time = 2, iterations = 3) +@Measurement(time = 2) +@Timeout(time = 2) +public class HqlParserBenchmarks { + + @State(Scope.Benchmark) + public static class BenchmarkParameters { + + DeclaredQuery query; + Sort sort = Sort.by("foo"); + QueryEnhancer enhancer; + QueryEnhancer.QueryRewriteInformation rewriteInformation; + + @Setup(Level.Iteration) + public void doSetup() { + + String s = """ + 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" + """; + + 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.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 new file mode 100644 index 0000000000..f4121c28ed --- /dev/null +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerBenchmarks.java @@ -0,0 +1,73 @@ +/* + * 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.io.IOException; + +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.Timeout; +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 + */ +@Testable +@Fork(1) +@Warmup(time = 2, iterations = 3) +@Measurement(time = 2) +@Timeout(time = 2) +public class JSqlParserQueryEnhancerBenchmarks { + + @State(Scope.Benchmark) + 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 { + + String s = """ + select SOME_COLUMN from SOME_TABLE where REPORTING_DATE = :REPORTING_DATE + except + 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.nativeQuery(s)); + rewriteInformation = new DefaultQueryRewriteInformation(sort, + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())); + } + } + + @Benchmark + public Object applySortWithParsing(BenchmarkParameters p) { + 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 3ed025efb5..18f7c6fb8c 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 @@ -18,10 +18,9 @@ grammar Eql; @header { /** * Implementation of EclipseLink Query Language (EQL) - * See: - * * https://eclipse.dev/eclipselink/documentation/3.0/jpa/extensions/jpql.htm - * * https://wiki.eclipse.org/EclipseLink/UserGuide/JPA/Basic_JPA_Development/Querying/JPQL * + * @see https://wiki.eclipse.org/EclipseLink/UserGuide/JPA/Basic_JPA_Development/Querying/JPQL + * @see https://eclipse.dev/eclipselink/documentation/3.0/jpa/extensions/jpql.htm * @author Greg Turnquist * @author Christoph Strobl * @since 3.2 @@ -43,7 +42,8 @@ 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)? # SelectQuery + | from_clause (where_clause)? (groupby_clause)? (having_clause)? (orderby_clause)? (set_fuction)? # FromQuery ; setOperator @@ -52,6 +52,10 @@ setOperator | EXCEPT ALL? ; +set_fuction + : setOperator select_statement + ; + update_statement : update_clause (where_clause)? ; @@ -68,7 +72,7 @@ from_clause identificationVariableDeclarationOrCollectionMemberDeclaration : identification_variable_declaration | collection_member_declaration - | '(' subquery ')' identification_variable + | '(' subquery ')' (AS? identification_variable)? ; identification_variable_declaration @@ -76,15 +80,15 @@ identification_variable_declaration ; range_variable_declaration - : (entity_name|function_invocation) AS? identification_variable + : (entity_name|function_invocation) (AS? identification_variable)? ; join - : join_spec join_association_path_expression AS? identification_variable? join_condition? + : join_spec join_association_path_expression (AS? identification_variable)? join_condition? ; fetch_join - : join_spec FETCH join_association_path_expression AS? identification_variable? join_condition? + : join_spec FETCH join_association_path_expression (AS? identification_variable)? join_condition? ; join_spec @@ -111,7 +115,7 @@ join_single_valued_path_expression ; collection_member_declaration - : IN '(' collection_valued_path_expression ')' AS? identification_variable + : IN '(' collection_valued_path_expression ')' (AS? identification_variable)? ; qualified_identification_variable @@ -211,11 +215,12 @@ constructor_item | scalar_expression | aggregate_expression | identification_variable + | literal ; aggregate_expression - : (AVG | MAX | MIN | SUM) '(' (DISTINCT)? state_valued_path_expression ')' - | COUNT '(' (DISTINCT)? (identification_variable | state_valued_path_expression | single_valued_object_path_expression) ')' + : (AVG | MAX | MIN | SUM) '(' (DISTINCT)? simple_select_expression ')' + | COUNT '(' (DISTINCT)? simple_select_expression ')' | function_invocation ; @@ -241,14 +246,15 @@ orderby_clause : ORDER BY orderby_item (',' orderby_item)* ; -// TODO Error in spec BNF, correctly shown elsewhere in spec. orderby_item - : state_field_path_expression (ASC | DESC)? nullsPrecedence? - | general_identification_variable (ASC | DESC)? nullsPrecedence? - | result_variable (ASC | DESC)? nullsPrecedence? - | string_expression (ASC | DESC)? nullsPrecedence? - | scalar_expression (ASC | DESC)? nullsPrecedence? - | + : orderby_expression (ASC | DESC)? nullsPrecedence? + ; + +orderby_expression + : state_field_path_expression + | general_identification_variable + | string_expression + | scalar_expression ; nullsPrecedence @@ -265,7 +271,7 @@ subquery_from_clause subselect_identification_variable_declaration : identification_variable_declaration - | derived_path_expression AS? identification_variable (join)* + | derived_path_expression (AS? identification_variable)? (join)* | derived_collection_member_declaration ; @@ -349,12 +355,17 @@ between_expression ; in_expression - : (state_valued_path_expression | type_discriminator) (NOT)? IN (('(' in_item (',' in_item)* ')') | ( '(' subquery ')') | collection_valued_input_parameter) + : (string_expression | type_discriminator) (NOT)? IN (('(' in_item (',' in_item)* ')') | ( '(' subquery ')') | collection_valued_input_parameter) ; in_item : literal + | string_expression + | boolean_literal + | numeric_literal + | date_time_timestamp_literal | single_valued_input_parameter + | conditional_expression ; like_expression @@ -436,7 +447,8 @@ arithmetic_primary | functions_returning_numerics | aggregate_expression | case_expression - | cast_function + | arithmetic_cast_function + | type_cast_function | function_invocation | '(' subquery ')' ; @@ -449,7 +461,10 @@ string_expression | aggregate_expression | case_expression | function_invocation + | string_cast_function + | type_cast_function | '(' subquery ')' + | string_expression '||' string_expression ; datetime_expression @@ -534,6 +549,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 @@ -542,8 +560,16 @@ trim_specification | BOTH ; -cast_function - : CAST '(' single_valued_path_expression identification_variable ('(' numeric_literal (',' numeric_literal)* ')')? ')' +arithmetic_cast_function + : CAST '(' string_expression (AS)? f=(INTEGER|LONG|FLOAT|DOUBLE) ')' + ; + +type_cast_function + : CAST '(' scalar_expression (AS)? identification_variable ('(' numeric_literal (',' numeric_literal)* ')')? ')' + ; + +string_cast_function + : CAST '(' scalar_expression (AS)? STRING ')' ; function_invocation @@ -567,10 +593,7 @@ datetime_part ; function_arg - : literal - | state_valued_path_expression - | input_parameter - | scalar_expression + : simple_select_expression ; case_expression @@ -609,6 +632,14 @@ nullif_expression : NULLIF '(' scalar_expression ',' scalar_expression ')' ; +type_literal + : STRING + | INTEGER + | LONG + | FLOAT + | DOUBLE + ; + /******************* Gaps in the spec. *******************/ @@ -621,6 +652,7 @@ trim_character identification_variable : IDENTIFICATION_VARIABLE | f=(COUNT + | AS | DATE | FROM | INNER @@ -630,11 +662,13 @@ identification_variable | ORDER | OUTER | POWER + | RIGHT | FLOOR | SIGN | TIME | TYPE | VALUE) + | type_literal ; constructor_name @@ -643,6 +677,7 @@ constructor_name literal : STRINGLITERAL + | JAVASTRINGLITERAL | INTLITERAL | FLOATLITERAL | LONGLITERAL @@ -672,7 +707,8 @@ entity_type_literal escape_character : CHARACTER - | character_valued_input_parameter // + | string_literal + | character_valued_input_parameter ; numeric_literal @@ -705,18 +741,22 @@ subtype collection_valued_field : identification_variable + | reserved_word ; single_valued_object_field : identification_variable + | reserved_word ; state_field : identification_variable + | reserved_word ; collection_value_field : identification_variable + | reserved_word ; entity_name @@ -812,6 +852,8 @@ reserved_word |ORDER |OUTER |POWER + |REPLACE + |RIGHT |ROUND |SELECT |SET @@ -837,7 +879,8 @@ reserved_word */ -WS : [ \t\r\n] -> skip ; +WS : [ \t\r\n] -> channel(HIDDEN) ; +COMMENT : '/*' (~'*' | '*' ~'/' )* '*/' -> skip; // Build up case-insentive tokens @@ -894,6 +937,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; @@ -906,6 +950,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; @@ -914,6 +959,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; @@ -926,6 +972,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; @@ -944,6 +991,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; @@ -951,6 +1000,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; @@ -970,9 +1020,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 7984d4881b..1e09319885 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 @@ -26,6 +26,8 @@ grammar Hql; * to simplify the processing. * * @author Greg Turnquist + * @author Mark Paluch + * @author Yannick Brandt * @since 3.1 */ } @@ -47,12 +49,12 @@ ql_statement // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-select selectStatement - : queryExpression - ; + : queryExpression + ; queryExpression - : withClause? orderedQuery (setOperator orderedQuery)* - ; + : withClause? orderedQuery (setOperator orderedQuery)* + ; withClause : WITH cte (',' cte)* @@ -83,25 +85,25 @@ cteAttributes ; orderedQuery - : (query | '(' queryExpression ')') queryOrder? - ; + : (query | '(' queryExpression ')') queryOrder? limitClause? offsetClause? fetchClause? + ; 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 - : orderByClause limitClause? offsetClause? fetchClause? - ; + : orderByClause + ; fromClause - : FROM entityWithJoins (',' entityWithJoins)* - ; + : FROM entityWithJoins (',' entityWithJoins)* + ; entityWithJoins - : fromRoot (joinSpecifier)* - ; + : fromRoot (joinSpecifier)* + ; joinSpecifier : join @@ -110,18 +112,20 @@ joinSpecifier ; fromRoot - : entityName variable? - | LATERAL? '(' subquery ')' variable? - ; + : entityName variable? # RootEntity + | LATERAL? '(' subquery ')' variable? # RootSubquery + | setReturningFunction variable? # RootFunction + ; join - : joinType JOIN FETCH? joinTarget joinRestriction? // Spec BNF says joinType isn't optional, but text says that it is. - ; + : joinType JOIN FETCH? joinTarget joinRestriction? // Spec BNF says joinType isn't optional, but text says that it is. + ; joinTarget - : path variable? # JoinPath - | LATERAL? '(' subquery ')' variable? # JoinSubquery - ; + : path variable? # JoinPath + | LATERAL? '(' subquery ')' variable? # JoinSubquery + | LATERAL? setReturningFunction variable? # JoinFunctionCall + ; // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-update updateStatement @@ -129,12 +133,12 @@ updateStatement ; targetEntity - : entityName variable? - ; + : entityName variable? + ; setClause - : SET assignment (',' assignment)* - ; + : SET assignment (',' assignment)* + ; assignment : simplePath '=' expressionOrPredicate @@ -147,32 +151,45 @@ deleteStatement // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-insert insertStatement - : INSERT INTO? targetEntity targetFields (queryExpression | valuesList) + : INSERT INTO? targetEntity targetFields (queryExpression | valuesList) conflictClause? ; // Already defined underneath updateStatement //targetEntity -// : entityName variable? -// ; +// : entityName variable? +// ; targetFields - : '(' simplePath (',' simplePath)* ')' - ; + : '(' simplePath (',' simplePath)* ')' + ; valuesList - : VALUES values (',' values)* - ; + : VALUES values (',' values)* + ; values - : '(' expression (',' expression)* ')' - ; + : '(' expression (',' expression)* ')' + ; -instantiation - : NEW instantiationTarget '(' instantiationArguments ')' +/** + * a 'conflict' clause in an 'insert' statement + */ +conflictClause + : ON CONFLICT conflictTarget? DO conflictAction + ; + +conflictTarget + : ON CONSTRAINT identifier + | '(' simplePath (',' simplePath)* ')' + ; + +conflictAction + : NOTHING + | UPDATE setClause whereClause? ; -alias - : AS? identifier // spec says IDENTIFIER but clearly does NOT mean a reserved word +instantiation + : NEW instantiationTarget '(' instantiationArguments ')' ; groupedItem @@ -247,8 +264,8 @@ mapEntrySelection * Deprecated syntax dating back to EJB-QL prior to EJB 3, required by JPA, never documented in Hibernate */ jpaSelectObjectSyntax - : OBJECT '(' identifier ')' - ; + : OBJECT '(' identifier ')' + ; whereClause : WHERE predicate (',' predicate)* @@ -294,12 +311,15 @@ setOperator // Literals // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-literals literal - : NULL + : STRING_LITERAL + | JAVA_STRING_LITERAL + | NULL | booleanLiteral - | stringLiteral | numericLiteral - | dateTimeLiteral | binaryLiteral + | temporalLiteral + | arrayLiteral + | generalizedLiteral ; // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-boolean-literals @@ -308,57 +328,222 @@ booleanLiteral | FALSE ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-string-literals -stringLiteral - : STRINGLITERAL - | JAVASTRINGLITERAL - | CHARACTER - ; - // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-numeric-literals numericLiteral : INTEGER_LITERAL + | LONG_LITERAL + | BIG_INTEGER_LITERAL | FLOAT_LITERAL - | HEXLITERAL + | DOUBLE_LITERAL + | BIG_DECIMAL_LITERAL + | HEX_LITERAL ; // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-datetime-literals +/** + * A literal datetime, in braces, or with the 'datetime' keyword + */ dateTimeLiteral - : LOCAL_DATE - | LOCAL_TIME - | LOCAL_DATETIME - | CURRENT_DATE - | CURRENT_TIME - | CURRENT_TIMESTAMP - | OFFSET_DATETIME - | (LOCAL | CURRENT) DATE - | (LOCAL | CURRENT) TIME - | (LOCAL | CURRENT | OFFSET) DATETIME - | INSTANT + : localDateTimeLiteral + | zonedDateTimeLiteral + | offsetDateTimeLiteral + ; + +localDateTimeLiteral + : '(' localDateTime ')' + | LOCAL? DATETIME localDateTime + ; + +zonedDateTimeLiteral + : '(' zonedDateTime ')' + | ZONED? DATETIME zonedDateTime + ; + +offsetDateTimeLiteral + : '(' offsetDateTime ')' + | OFFSET? DATETIME offsetDateTimeWithMinutes + ; +/** + * A literal date, in braces, or with the 'date' keyword + */ +dateLiteral + : '(' date ')' + | LOCAL? DATE date + ; + +/** + * A literal time, in braces, or with the 'time' keyword + */ +timeLiteral + : '(' time ')' + | LOCAL? TIME time + ; + +/** + * A literal datetime + */ + dateTime + : localDateTime + | zonedDateTime + | offsetDateTime + ; + +localDateTime + : date time + ; + +zonedDateTime + : date time zoneId + ; + +offsetDateTime + : date time offset + ; + +offsetDateTimeWithMinutes + : date time offsetWithMinutes + ; + +/** + * A JDBC-style timestamp escape, as required by JPQL + */ +jdbcTimestampLiteral + : TIMESTAMP_ESCAPE_START (dateTime | genericTemporalLiteralText) '}' + ; + +/** + * A JDBC-style date escape, as required by JPQL + */ +jdbcDateLiteral + : DATE_ESCAPE_START (date | genericTemporalLiteralText) '}' + ; + +/** + * A JDBC-style time escape, as required by JPQL + */ +jdbcTimeLiteral + : TIME_ESCAPE_START (time | genericTemporalLiteralText) '}' + ; + +genericTemporalLiteralText + : STRING_LITERAL + ; + +/** + * A generic format for specifying literal values of arbitary types + */ +arrayLiteral + : '[' (expression (',' expression)*)? ']' + ; + +/** + * A generic format for specifying literal values of arbitary types + */ +generalizedLiteral + : '(' generalizedLiteralType ':' generalizedLiteralText ')' + ; + +generalizedLiteralType : STRING_LITERAL; +generalizedLiteralText : STRING_LITERAL; + +/** + * A literal date + */ +date + : year '-' month '-' day + ; + +/** + * A literal time + */ +time + : hour ':' minute (':' second)? + ; + +/** + * A literal offset + */ +offset + : (PLUS | MINUS) hour (':' minute)? + ; + +offsetWithMinutes + : (PLUS | MINUS) hour ':' minute + ; + +year: INTEGER_LITERAL; +month: INTEGER_LITERAL; +day: INTEGER_LITERAL; +hour: INTEGER_LITERAL; +minute: INTEGER_LITERAL; +second: INTEGER_LITERAL | DOUBLE_LITERAL; +zoneId + : IDENTIFIER ('/' IDENTIFIER)? + | STRING_LITERAL; + +/** + * A field that may be extracted from a date, time, or datetime + */ +extractField + : datetimeField + | dayField + | weekField + | timeZoneField + | dateOrTimeField ; // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-duration-literals datetimeField - : YEAR - | MONTH - | DAY - | WEEK - | QUARTER - | HOUR - | MINUTE - | SECOND - | NANOSECOND - | EPOCH - ; + : YEAR + | MONTH + | DAY + | WEEK + | QUARTER + | HOUR + | MINUTE + | SECOND + | NANOSECOND + | EPOCH + ; + +dayField + : DAY OF MONTH + | DAY OF WEEK + | DAY OF YEAR + ; + +weekField + : WEEK OF MONTH + | WEEK OF YEAR + ; + +timeZoneField + : OFFSET (HOUR | MINUTE)? + | TIMEZONE_HOUR | TIMEZONE_MINUTE + ; + +dateOrTimeField + : DATE + | TIME + ; // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-binary-literals binaryLiteral : BINARY_LITERAL - | '{' HEXLITERAL (',' HEXLITERAL)* '}' + | '{' HEX_LITERAL (',' HEX_LITERAL)* '}' ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-enum-literals -// TBD +/** + * A literal date, time, or datetime, in HQL syntax, or as a JDBC-style "escape" syntax + */ +temporalLiteral + : dateTimeLiteral + | dateLiteral + | timeLiteral + | jdbcTimestampLiteral + | jdbcDateLiteral + | jdbcTimeLiteral + ; // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-java-constants // TBD @@ -387,197 +572,872 @@ expression | WEEK OF YEAR # WeekOfYearExpression ; -primaryExpression - : caseList # CaseExpression - | literal # LiteralExpression - | parameter # ParameterExpression - | function # FunctionExpression - | generalPathFragment # GeneralPathExpression +primaryExpression + : caseList # CaseExpression + | literal # LiteralExpression + | parameter # ParameterExpression + | entityTypeReference # EntityTypeExpression + | entityIdReference # EntityIdExpression + | entityVersionReference # EntityVersionExpression + | entityNaturalIdReference # EntityNaturalIdExpression + | syntacticDomainPath pathContinuation? # SyntacticPathExpression + | function # FunctionExpression + | generalPathFragment # GeneralPathExpression + ; + +/** + * A much more complicated path expression involving operators and functions + * + * A path which needs to be resolved semantically. This recognizes + * any path-like structure. Generally, the path is semantically + * interpreted by the consumer of the parse-tree. However, there + * are certain cases where we can syntactically recognize a navigable + * path; see 'syntacticNavigablePath' rule + */ +path + : syntacticDomainPath pathContinuation? + | generalPathFragment + ; + +generalPathFragment + : simplePath indexedPathAccessFragment? + ; + +indexedPathAccessFragment + : '[' expression ']' ('.' generalPathFragment)? + ; + +/** + * A simple path expression + * + * - a reference to an identification variable (not case-sensitive), + * - followed by a list of period-separated identifiers (case-sensitive) + */ +simplePath + : identifier simplePathElement* + ; + +/** + * An element of a simple path expression: a period, and an identifier (case-sensitive) + */ +simplePathElement + : '.' identifier + ; + +/** + * A continuation of a path expression "broken" by an operator or function + */ +pathContinuation + : '.' simplePath + ; + +/** + * The special function 'type()' + */ +entityTypeReference + : TYPE '(' (path | parameter) ')' + ; + +/** + * The special function 'id()' + */ +entityIdReference + : ID '(' path ')' pathContinuation? + ; + +/** + * The special function 'version()' + */ +entityVersionReference + : VERSION '(' path ')' + ; + +/** + * The special function 'naturalid()' + */ +entityNaturalIdReference + : NATURALID '(' path ')' pathContinuation? + ; + +/** + * An operator or function that may occur within a path expression + * + * Rule for cases where we syntactically know that the path is a + * "domain path" because it is one of these special cases: + * + * * TREAT( path ) + * * ELEMENTS( path ) + * * INDICES( path ) + * * VALUE( path ) + * * KEY( path ) + * * path[ selector ] + * * ARRAY_GET( embeddableArrayPath, index ).path + * * COALESCE( array1, array2 )[ selector ].path + */ +syntacticDomainPath + : treatedNavigablePath + | collectionValueNavigablePath + | mapKeyNavigablePath + | simplePath indexedPathAccessFragment + | simplePath slicedPathAccessFragment + | toOneFkReference + | function pathContinuation + | function indexedPathAccessFragment pathContinuation? + | function slicedPathAccessFragment + ; + +/** + * The slice operator to obtain elements between the lower and upper bound. + */ +slicedPathAccessFragment + : '[' expression ':' expression ']' + ; + +/** + * A 'treat()' function that "breaks" a path expression + */ +treatedNavigablePath + : TREAT '(' path AS simplePath ')' pathContinuation? + ; + +/** + * A 'value()' function that "breaks" a path expression + */ +collectionValueNavigablePath + : elementValueQuantifier '(' path ')' pathContinuation? + ; + +/** + * A 'key()' or 'index()' function that "breaks" a path expression + */ +mapKeyNavigablePath + : indexKeyQuantifier '(' path ')' pathContinuation? + ; + +/** + * The special function 'fk()' + */ +toOneFkReference + : FK '(' path ')' + ; + +// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-case-expressions +caseList + : simpleCaseExpression + | searchedCaseExpression + ; + +// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-simple-case-expressions +simpleCaseExpression + : CASE expressionOrPredicate caseWhenExpressionClause+ (ELSE expressionOrPredicate)? END + ; + +// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-searched-case-expressions +searchedCaseExpression + : CASE caseWhenPredicateClause+ (ELSE expressionOrPredicate)? END + ; + +caseWhenExpressionClause + : WHEN expression THEN expressionOrPredicate + ; + +caseWhenPredicateClause + : WHEN predicate THEN expressionOrPredicate + ; + +// Functions +/** + * A function invocation that may occur in an arbitrary expression + */ +function + : standardFunction # StandardFunctionInvocation + | aggregateFunction # AggregateFunctionInvocation + | collectionSizeFunction # CollectionSizeFunctionInvocation + | collectionAggregateFunction # CollectionAggregateFunctionInvocation + | collectionFunctionMisuse # CollectionFunctionMisuseInvocation + | jpaNonstandardFunction # JpaNonstandardFunctionInvocation + | columnFunction # ColumnFunctionInvocation + | jsonFunction # JsonFunctionInvocation + | xmlFunction # XmlFunctionInvocation + | genericFunction # GenericFunctionInvocation + ; + +setReturningFunction + : simpleSetReturningFunction + | jsonTableFunction + | xmlTableFunction + ; + +simpleSetReturningFunction + : identifier '(' genericFunctionArguments? ')' + ; + +/** + * Any function with an irregular syntax for the argument list + * + * These are all inspired by the syntax of ANSI SQL + */ +standardFunction + : castFunction + | treatedNavigablePath + | extractFunction + | truncFunction + | formatFunction + | collateFunction + | substringFunction + | overlayFunction + | trimFunction + | padFunction + | positionFunction + | currentDateFunction + | currentTimeFunction + | currentTimestampFunction + | instantFunction + | localDateFunction + | localTimeFunction + | localDateTimeFunction + | offsetDateTimeFunction + | cube + | rollup + ; + +/** + * The 'cast()' function for typecasting + */ +castFunction + : CAST '(' expression AS castTarget ')' + ; + +/** + * The target type for a typecast: a typename, together with length or precision/scale + */ +castTarget + : castTargetType ('(' INTEGER_LITERAL (',' INTEGER_LITERAL)? ')')? + ; + +/** + * The name of the target type in a typecast + * + * Like the 'entityName' rule, we have a specialized dotIdentifierSequence rule + */ +castTargetType + returns [String fullTargetName] + : (i=identifier { $fullTargetName = _localctx.i.getText(); }) ('.' c=identifier { $fullTargetName += ("." + _localctx.c.getText() ); })* + ; + +/** + * The two formats for the 'substring() function: one defined by JPQL, the other by ANSI SQL + */ +substringFunction + : SUBSTRING '(' expression ',' substringFunctionStartArgument (',' substringFunctionLengthArgument)? ')' + | SUBSTRING '(' expression FROM substringFunctionStartArgument (FOR substringFunctionLengthArgument)? ')' + ; + +substringFunctionStartArgument + : expression + ; + +substringFunctionLengthArgument + : expression + ; + +/** + * The ANSI SQL-style 'trim()' function + */ +trimFunction + : TRIM '(' trimSpecification? trimCharacter? FROM? expression ')' + ; + +trimSpecification + : LEADING + | TRAILING + | BOTH + ; + +trimCharacter + : STRING_LITERAL + | parameter + ; + +/** + * A 'pad()' function inspired by 'trim()' + */ +padFunction + : PAD '(' expression WITH padLength padSpecification padCharacter? ')' + ; + +padSpecification + : LEADING + | TRAILING + ; + +padCharacter + : STRING_LITERAL + ; + +padLength + : expression + ; + +/** + * The ANSI SQL-style 'position()' function + */ +positionFunction + : POSITION '(' positionFunctionPatternArgument IN positionFunctionStringArgument ')' + ; + +positionFunctionPatternArgument + : expression + ; + +positionFunctionStringArgument + : expression + ; + +/** + * The ANSI SQL-style 'overlay()' function + */ +overlayFunction + : OVERLAY '(' overlayFunctionStringArgument PLACING overlayFunctionReplacementArgument FROM overlayFunctionStartArgument (FOR overlayFunctionLengthArgument)? ')' + ; + +overlayFunctionStringArgument + : expression + ; + +overlayFunctionReplacementArgument + : expression + ; + +overlayFunctionStartArgument + : expression + ; + +overlayFunctionLengthArgument + : expression + ; + +/** + * The deprecated current_date function required by JPQL + */ +currentDateFunction + : CURRENT_DATE ('(' ')')? + | CURRENT DATE + ; + +/** + * The deprecated current_time function required by JPQL + */ +currentTimeFunction + : CURRENT_TIME ('(' ')')? + | CURRENT TIME + ; + +/** + * The deprecated current_timestamp function required by JPQL + */ +currentTimestampFunction + : CURRENT_TIMESTAMP ('(' ')')? + | CURRENT TIMESTAMP + ; + +/** + * The instant function, and deprecated current_instant function + */ +instantFunction + : CURRENT_INSTANT ('(' ')')? //deprecated legacy syntax + | INSTANT + ; + +/** + * The 'local datetime' function (or literal if you prefer) + */ +localDateTimeFunction + : LOCAL_DATETIME ('(' ')')? + | LOCAL DATETIME + ; + +/** + * The 'offset datetime' function (or literal if you prefer) + */ +offsetDateTimeFunction + : OFFSET_DATETIME ('(' ')')? + | OFFSET DATETIME + ; + +/** + * The 'local date' function (or literal if you prefer) + */ +localDateFunction + : LOCAL_DATE ('(' ')')? + | LOCAL DATE + ; + +/** + * The 'local time' function (or literal if you prefer) + */ +localTimeFunction + : LOCAL_TIME ('(' ')')? + | LOCAL TIME + ; + +/** + * The 'format()' function for formatting dates and times according to a pattern + */ +formatFunction + : FORMAT '(' expression AS format ')' + ; + +/** + * The name of a database-defined collation + * + * Certain databases allow a period in a collation name + */ +collation + : simplePath + ; + +/** + * The special 'collate()' functions + */ +collateFunction + : COLLATE '(' expression AS collation ')' + ; + +/** + * The 'cube()' function specific to the 'group by' clause + */ +cube + : CUBE '(' expressionOrPredicate (',' expressionOrPredicate)* ')' + ; + +/** + * The 'rollup()' function specific to the 'group by' clause + */ +rollup + : ROLLUP '(' expressionOrPredicate (',' expressionOrPredicate)* ')' + ; + +/** + * A format pattern, with a syntax inspired by by java.time.format.DateTimeFormatter + * + * see 'Dialect.appendDatetimeFormat()' + */ +format + : STRING_LITERAL + ; + +/** + * The 'extract()' function for extracting fields of dates, times, and datetimes + */ +extractFunction + : EXTRACT '(' extractField FROM expression ')' + | datetimeField '(' expression ')' + ; + +/** + * The 'trunc()' function for truncating both numeric and datetime values + */ +truncFunction + : (TRUNC | TRUNCATE) '(' expression (',' (datetimeField | expression))? ')' + ; + +/** + * A syntax for calling user-defined or native database functions, required by JPQL + */ +jpaNonstandardFunction + : FUNCTION '(' jpaNonstandardFunctionName (AS castTarget)? (',' genericFunctionArguments)? ')' ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-Datetime-arithmetic -// TBD +/** + * The name of a user-defined or native database function, given as a quoted string + */ +jpaNonstandardFunctionName + : STRING_LITERAL + | identifier + ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-path-expressions -identificationVariable - : identifier - | simplePath +columnFunction + : COLUMN '(' path '.' jpaNonstandardFunctionName (AS castTarget)? ')' ; -path - : treatedPath pathContinutation? - | generalPathFragment +/** + * Any function invocation that follows the regular syntax + * + * The function name, followed by a parenthesized list of ','-separated expressions + */ +genericFunction + : genericFunctionName '(' (genericFunctionArguments | ASTERISK)? ')' pathContinuation? + nthSideClause? nullsClause? withinGroupClause? filterClause? overClause? ; -generalPathFragment - : simplePath indexedPathAccessFragment? +/** + * The name of a generic function, which may contain periods and quoted identifiers + * + * Names of generic functions are resolved against the SqmFunctionRegistry + */ +genericFunctionName + : simplePath ; -indexedPathAccessFragment - : '[' expression ']' ('.' generalPathFragment)? - ; +/** + * The arguments of a generic function + */ +genericFunctionArguments + : (DISTINCT | datetimeField ',')? expressionOrPredicate (',' expressionOrPredicate)* + ; -simplePath - : identifier simplePathElement* +/** + * The special 'size()' function defined by JPQL + */ +collectionSizeFunction + : SIZE '(' path ')' ; -simplePathElement - : '.' identifier +/** + * Special rule for 'max(elements())`, 'avg(keys())', 'sum(indices())`, etc., as defined by HQL + * Also the deprecated 'maxindex()', 'maxelement()', 'minindex()', 'minelement()' functions from old HQL + */ +collectionAggregateFunction + : (MAX|MIN|SUM|AVG) '(' elementsValuesQuantifier '(' path ')' ')' # ElementAggregateFunction + | (MAX|MIN|SUM|AVG) '(' indicesKeysQuantifier '(' path ')' ')' # IndexAggregateFunction + | (MAXELEMENT|MINELEMENT) '(' path ')' # ElementAggregateFunction + | (MAXINDEX|MININDEX) '(' path ')' # IndexAggregateFunction ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-case-expressions -caseList - : simpleCaseExpression - | searchedCaseExpression +/** + * To accommodate the misuse of elements() and indices() in the select clause + * + * (At some stage in the history of HQL, someone mixed them up with value() and index(), + * and so we have tests that insist they're interchangeable. Ugh.) + */ +collectionFunctionMisuse + : elementsValuesQuantifier '(' path ')' + | indicesKeysQuantifier '(' path ')' ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-simple-case-expressions -simpleCaseExpression - : CASE expressionOrPredicate caseWhenExpressionClause+ (ELSE expressionOrPredicate)? END +/** + * The special 'every()', 'all()', 'any()' and 'some()' functions defined by HQL + * + * May be applied to a subquery or collection reference, or may occur as an aggregate function in the 'select' clause + */ +aggregateFunction + : everyFunction + | anyFunction + | listaggFunction ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-searched-case-expressions -searchedCaseExpression - : CASE caseWhenPredicateClause+ (ELSE expressionOrPredicate)? END +/** + * The functions 'every()' and 'all()' are synonyms + */ +everyFunction + : everyAllQuantifier '(' predicate ')' filterClause? overClause? + | everyAllQuantifier '(' subquery ')' + | everyAllQuantifier collectionQuantifier '(' simplePath ')' ; -caseWhenExpressionClause - : WHEN expression THEN expressionOrPredicate +/** + * The functions 'any()' and 'some()' are synonyms + */ +anyFunction + : anySomeQuantifier '(' predicate ')' filterClause? overClause? + | anySomeQuantifier '(' subquery ')' + | anySomeQuantifier collectionQuantifier '(' simplePath ')' ; -caseWhenPredicateClause - : WHEN predicate THEN expressionOrPredicate +everyAllQuantifier + : EVERY + | ALL ; -// Functions -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-exp-functions -function - : functionName '(' (functionArguments | ASTERISK)? ')' pathContinutation? filterClause? withinGroup? overClause? # GenericFunction - | functionName '(' subquery ')' # FunctionWithSubquery - | castFunction # CastFunctionInvocation - | extractFunction # ExtractFunctionInvocation - | trimFunction # TrimFunctionInvocation - | everyFunction # EveryFunctionInvocation - | anyFunction # AnyFunctionInvocation - | treatedPath # TreatedPathInvocation +anySomeQuantifier + : ANY + | SOME + ; + +/** + * The 'listagg()' ordered set-aggregate function + */ +listaggFunction + : LISTAGG '(' DISTINCT? expressionOrPredicate ',' expressionOrPredicate onOverflowClause? ')' + withinGroupClause? filterClause? overClause? + ; + +/** + * A 'on overflow' clause: what to do when the text data type used for 'listagg' overflows + */ +onOverflowClause + : ON OVERFLOW (ERROR | TRUNCATE expression? (WITH|WITHOUT) COUNT) ; -functionArguments - : DISTINCT? expressionOrPredicate (',' expressionOrPredicate)* +/** + * A 'within group' clause: defines the order in which the ordered set-aggregate function should work + */ +withinGroupClause + : WITHIN GROUP '(' orderByClause ')' ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-aggregate-functions-filter +/** + * A 'filter' clause: a restriction applied to an aggregate function + */ filterClause : FILTER '(' whereClause ')' ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-aggregate-functions-orderedset -withinGroup - : WITHIN GROUP '(' orderByClause ')' +/** + * A `nulls` clause: what should a value access window function do when encountering a `null` + */ +nullsClause + : RESPECT NULLS + | IGNORE NULLS + ; + +/** + * A `nulls` clause: what should a value access window function do when encountering a `null` + */ +nthSideClause + : FROM FIRST + | FROM LAST ; +/** + * A 'over' clause: the specification of a window within which the function should act + */ overClause : OVER '(' partitionClause? orderByClause? frameClause? ')' ; +/** + * A 'partition' clause: the specification the group within which a function should act in a window + */ partitionClause : PARTITION BY expression (',' expression)* ; +/** + * A 'frame' clause: the specification the content of the window + */ frameClause : (RANGE|ROWS|GROUPS) frameStart frameExclusion? | (RANGE|ROWS|GROUPS) BETWEEN frameStart AND frameEnd frameExclusion? ; +/** + * The start of the window content + */ frameStart - : UNBOUNDED PRECEDING # UnboundedPrecedingFrameStart - | expression PRECEDING # ExpressionPrecedingFrameStart - | CURRENT ROW # CurrentRowFrameStart - | expression FOLLOWING # ExpressionFollowingFrameStart - ; + : CURRENT ROW + | UNBOUNDED PRECEDING + | expression PRECEDING + | expression FOLLOWING + ; +/** + * The end of the window content + */ +frameEnd + : CURRENT ROW + | UNBOUNDED FOLLOWING + | expression PRECEDING + | expression FOLLOWING + ; + +/** + * A 'exclusion' clause: the specification what to exclude from the window content + */ frameExclusion - : EXCLUDE CURRENT ROW # CurrentRowFrameExclusion - | EXCLUDE GROUP # GroupFrameExclusion - | EXCLUDE TIES # TiesFrameExclusion - | EXCLUDE NO OTHERS # NoOthersFrameExclusion + : EXCLUDE CURRENT ROW + | EXCLUDE GROUP + | EXCLUDE TIES + | EXCLUDE NO OTHERS ; -frameEnd - : expression PRECEDING # ExpressionPrecedingFrameEnd - | CURRENT ROW # CurrentRowFrameEnd - | expression FOLLOWING # ExpressionFollowingFrameEnd - | UNBOUNDED FOLLOWING # UnboundedFollowingFrameEnd +// JSON Functions + +jsonFunction + : jsonArrayFunction + | jsonExistsFunction + | jsonObjectFunction + | jsonQueryFunction + | jsonValueFunction + | jsonArrayAggFunction + | jsonObjectAggFunction ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-functions -castFunction - : CAST '(' expression AS castTarget ')' +/** + * The 'json_array(… ABSENT ON NULL)' function + */ +jsonArrayFunction + : JSON_ARRAY '(' (expressionOrPredicate (',' expressionOrPredicate)* jsonNullClause?)? ')'; + +/** + * The 'json_exists(, PASSING … AS … WITH WRAPPER ERROR|NULL|DEFAULT on ERROR|EMPTY)' function + */ +jsonExistsFunction + : JSON_EXISTS '(' expression ',' expression jsonPassingClause? jsonExistsOnErrorClause? ')'; + +jsonExistsOnErrorClause + : (ERROR | TRUE | FALSE) ON ERROR ; -castTarget - : castTargetType ('(' INTEGER_LITERAL (',' INTEGER_LITERAL)? ')')? - ; +/** + * The 'json_object( foo, bar, KEY foo VALUE bar, foo:bar ABSENT ON NULL)' function + */ +jsonObjectFunction + : JSON_OBJECT '(' jsonObjectFunctionEntry? (',' jsonObjectFunctionEntry)* jsonNullClause? ')'; -castTargetType - returns [String fullTargetName] - : (i=identifier { $fullTargetName = _localctx.i.getText(); }) ('.' c=identifier { $fullTargetName += ("." + _localctx.c.getText() ); })* - ; +jsonObjectFunctionEntry + : (expressionOrPredicate|jsonObjectKeyValueEntry|jsonObjectAssignmentEntry); -extractFunction - : EXTRACT '(' expression FROM expression ')' - | dateTimeFunction '(' expression ')' +jsonObjectKeyValueEntry + : KEY? expressionOrPredicate VALUE expressionOrPredicate; + +jsonObjectAssignmentEntry + : expressionOrPredicate ':' expressionOrPredicate; + +/** + * The 'json_query(, PASSING … AS … WITH WRAPPER ERROR|NULL|DEFAULT on ERROR|EMPTY)' function + */ +jsonQueryFunction + : JSON_QUERY '(' expression ',' expression jsonPassingClause? jsonQueryWrapperClause? jsonQueryOnErrorOrEmptyClause? jsonQueryOnErrorOrEmptyClause? ')'; + +jsonQueryWrapperClause + : WITH (CONDITIONAL | UNCONDITIONAL)? ARRAY? WRAPPER + | WITHOUT ARRAY? WRAPPER ; -trimFunction - : TRIM '(' (LEADING | TRAILING | BOTH)? stringLiteral? FROM? expression ')' +jsonQueryOnErrorOrEmptyClause + : (ERROR | NULL | EMPTY (ARRAY | OBJECT)?) ON (ERROR | EMPTY); + +/** + * The 'json_value(… , PASSING … AS … RETURNING … ERROR|NULL|DEFAULT on ERROR|EMPTY)' function + */ +jsonValueFunction + : JSON_VALUE '(' expression ',' expression jsonPassingClause? jsonValueReturningClause? jsonValueOnErrorOrEmptyClause? jsonValueOnErrorOrEmptyClause? ')' ; -dateTimeFunction - : d=(YEAR - | MONTH - | DAY - | WEEK - | QUARTER - | HOUR - | MINUTE - | SECOND - | NANOSECOND - | EPOCH) +jsonValueReturningClause + : RETURNING castTarget ; -everyFunction - : every=(EVERY | ALL) '(' predicate ')' - | every=(EVERY | ALL) '(' subquery ')' - | every=(EVERY | ALL) (ELEMENTS | INDICES) '(' simplePath ')' +jsonValueOnErrorOrEmptyClause + : (ERROR | NULL | DEFAULT expression) ON (ERROR | EMPTY) ; -anyFunction - : any=(ANY | SOME) '(' predicate ')' - | any=(ANY | SOME) '(' subquery ')' - | any=(ANY | SOME) (ELEMENTS | INDICES) '(' simplePath ')' +/** + * The 'json_arrayagg( …, ABSENT ON NULL ORDER BY)' function + */ +jsonArrayAggFunction + : JSON_ARRAYAGG '(' expressionOrPredicate jsonNullClause? orderByClause? ')' filterClause?; + +/** + * The 'json_objectagg( KEY? …, ABSENT ON NULL ORDER BY WITH|WITHOUT UNIQUE KEYS)' function + */ +jsonObjectAggFunction + : JSON_OBJECTAGG '(' KEY? expressionOrPredicate (VALUE | ':') expressionOrPredicate jsonNullClause? jsonUniqueKeysClause? ')' filterClause?; + +jsonPassingClause + : PASSING aliasedExpressionOrPredicate (',' aliasedExpressionOrPredicate)* ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-treat-type -treatedPath - : TREAT '(' path AS simplePath')' pathContinutation? +jsonNullClause + : (ABSENT | NULL) ON NULL; + +jsonUniqueKeysClause + : (WITH | WITHOUT) UNIQUE KEYS; + +/** + * The 'json_table(…, …, PASSING COLUMNS(…) ERROR|NULL ON ERROR)' function + */ +jsonTableFunction + : JSON_TABLE '(' expression (',' expression)? jsonPassingClause? jsonTableColumnsClause jsonTableErrorClause? ')'; + +jsonTableErrorClause + : (ERROR | NULL) ON ERROR; + +jsonTableColumnsClause + : COLUMNS '(' jsonTableColumns ')'; + +jsonTableColumns + : jsonTableColumn (',' jsonTableColumn)*; + +jsonTableColumn + : NESTED PATH? STRING_LITERAL jsonTableColumnsClause # JsonTableNestedColumn + | identifier JSON jsonQueryWrapperClause? (PATH STRING_LITERAL)? jsonQueryOnErrorOrEmptyClause? jsonQueryOnErrorOrEmptyClause? # JsonTableQueryColumn + | identifier FOR ORDINALITY # JsonTableOrdinalityColumn + | identifier EXISTS (PATH STRING_LITERAL)? jsonExistsOnErrorClause? # JsonTableExistsColumn + | identifier castTarget (PATH STRING_LITERAL)? jsonValueOnErrorOrEmptyClause? jsonValueOnErrorOrEmptyClause? # JsonTableValueColumn ; -pathContinutation - : '.' simplePath +xmlFunction + : xmlElementFunction + | xmlForestFunction + | xmlPiFunction + | xmlQueryFunction + | xmlExistsFunction + | xmlAggFunction + ; + +xmlElementFunction + : XMLELEMENT '(' NAME identifier (',' xmlAttributesFunction)? (',' expressionOrPredicate)* ')' + ; + +xmlAttributesFunction + : XMLATTRIBUTES '(' aliasedExpressionOrPredicate (',' aliasedExpressionOrPredicate)* ')' + ; + +xmlForestFunction + : XMLFOREST '(' potentiallyAliasedExpressionOrPredicate (',' potentiallyAliasedExpressionOrPredicate)* ')' + ; + +xmlPiFunction + : XMLPI '(' NAME identifier (',' expression)? ')'; + +xmlQueryFunction + : XMLQUERY '(' expression PASSING expression ')'; + +xmlExistsFunction + : XMLEXISTS '(' expression PASSING expression ')'; + +xmlAggFunction + : XMLAGG '(' expression orderByClause? ')' filterClause? overClause?; + +aliasedExpressionOrPredicate + : expressionOrPredicate AS identifier + ; + +potentiallyAliasedExpressionOrPredicate + : expressionOrPredicate (AS identifier)? + ; + +xmlTableFunction + : XMLTABLE '(' expression PASSING expression xmlTableColumnsClause ')'; + +xmlTableColumnsClause + : COLUMNS xmlTableColumn (',' xmlTableColumn)*; + +xmlTableColumn + : identifier XML (PATH STRING_LITERAL)? xmltableDefaultClause? # XmlTableQueryColumn + | identifier FOR ORDINALITY # XmlTableOrdinalityColumn + | identifier castTarget (PATH STRING_LITERAL)? xmltableDefaultClause? # XmlTableValueColumn ; +xmltableDefaultClause + : DEFAULT expression; + // Predicates // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-conditional-expressions predicate : '(' predicate ')' # GroupedPredicate - | dealingWithNullExpression # NullExpressionPredicate + | expression IS NOT? (NULL|EMPTY|TRUE|FALSE) # IsBooleanPredicate + | expression IS NOT? DISTINCT FROM expression # IsDistinctFromPredicate + | expression NOT? MEMBER OF? path # MemberOfPredicate | inExpression # InPredicate | betweenExpression # BetweenPredicate + | expression NOT? (CONTAINS|INCLUDES|INTERSECTS) expression # ContainsPredicate | relationalExpression # RelationalPredicate | stringPatternMatching # LikePredicate | existsExpression # ExistsPredicate - | collectionExpression # CollectionPredicate | NOT predicate # NotPredicate | predicate AND predicate # AndPredicate | predicate OR predicate # OrPredicate @@ -589,6 +1449,31 @@ expressionOrPredicate | predicate ; +collectionQuantifier + : elementsValuesQuantifier + | indicesKeysQuantifier + ; + +elementsValuesQuantifier + : ELEMENTS + | VALUES + ; + +elementValueQuantifier + : ELEMENT + | VALUE + ; + +indexKeyQuantifier + : INDEX + | KEY + ; + +indicesKeysQuantifier + : INDICES + | KEYS + ; + // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-relational-comparisons // NOTE: The TIP shows that "!=" is also supported. Hibernate's source code shows that "^=" is another NOT_EQUALS option as well. relationalExpression @@ -600,15 +1485,9 @@ betweenExpression : expression NOT? BETWEEN expression AND expression ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-null-predicate -dealingWithNullExpression - : expression IS NOT? NULL - | expression IS NOT? DISTINCT FROM expression - ; - // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-like-predicate stringPatternMatching - : expression NOT? (LIKE | ILIKE) expression (ESCAPE (stringLiteral|parameter))? + : expression NOT? (LIKE | ILIKE) expression (ESCAPE (STRING_LITERAL | JAVA_STRING_LITERAL |parameter))? ; // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-elements-indices @@ -620,11 +1499,11 @@ inExpression ; inList - : (ELEMENTS | INDICES) '(' simplePath ')' - | '(' subquery ')' - | parameter - | '(' (expressionOrPredicate (',' expressionOrPredicate)*)? ')' - ; + : (ELEMENTS | INDICES) '(' simplePath ')' + | '(' subquery ')' + | parameter + | '(' (expressionOrPredicate (',' expressionOrPredicate)*)? ')' + ; // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-exists-predicate existsExpression @@ -632,12 +1511,6 @@ existsExpression | EXISTS expression ; -// https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-collection-operators -collectionExpression - : expression IS NOT? EMPTY - | expression NOT? MEMBER OF path - ; - // Projection // https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#hql-select-new instantiationTarget @@ -651,8 +1524,8 @@ instantiationArguments ; instantiationArgument - : (expressionOrPredicate | instantiation) variable? - ; + : (expressionOrPredicate | instantiation) variable? + ; // Low level parsing rules @@ -666,9 +1539,12 @@ parameterOrNumberLiteral | numericLiteral ; +/** + * An identification variable (an entity alias) + */ variable : AS identifier - | reservedWord + | nakedIdentifier ; parameter @@ -680,195 +1556,230 @@ entityName : identifier ('.' identifier)* ; +nakedIdentifier + : IDENTIFIER + | QUOTED_IDENTIFIER + | f=(ABSENT + | ALL + | AND + | ANY + | ARRAY + | AS + | ASC + | AVG + | BETWEEN + | BOTH + | BREADTH + | BY + | CASE + | CAST + | COLLATE + | COLUMN + | COLUMNS + | CONDITIONAL + | CONFLICT + | CONSTRAINT + | CONTAINS + | COUNT + | CROSS + | CUBE + | CURRENT + | CURRENT_DATE + | CURRENT_INSTANT + | CURRENT_TIME + | CURRENT_TIMESTAMP + | CYCLE + | DATE + | DATETIME + | DAY + | DEFAULT + | DELETE + | DEPTH + | DESC + | DISTINCT + | DO + | ELEMENT + | ELEMENTS + | ELSE + | EMPTY + | END + | ENTRY + | EPOCH + | ERROR + | ESCAPE + | EVERY + | EXCEPT + | EXCLUDE + | EXISTS + | EXTRACT + | FETCH + | FILTER + | FIRST + | FK + | FOLLOWING + | FOR + | FORMAT + | FROM + | FUNCTION + | GROUP + | GROUPS + | HAVING + | HOUR + | ID + | IGNORE + | ILIKE + | IN + | INDEX + | INCLUDES + | INDICES + | INSERT + | INSTANT + | INTERSECT + | INTERSECTS + | INTO + | IS + | JOIN + | JSON + | JSON_ARRAY + | JSON_ARRAYAGG + | JSON_EXISTS + | JSON_OBJECT + | JSON_OBJECTAGG + | JSON_QUERY + | JSON_TABLE + | JSON_VALUE + | KEY + | KEYS + | LAST + | LATERAL + | LEADING + | LIKE + | LIMIT + | LIST + | LISTAGG + | LOCAL + | LOCAL_DATE + | LOCAL_DATETIME + | LOCAL_TIME + | MAP + | MATERIALIZED + | MAX + | MAXELEMENT + | MAXINDEX + | MEMBER + | MICROSECOND + | MILLISECOND + | MIN + | MINELEMENT + | MININDEX + | MINUTE + | MONTH + | NAME + | NANOSECOND + | NATURALID + | NEW + | NESTED + | NEXT + | NO + | NOT + | NOTHING + | NULLS + | OBJECT + | OF + | OFFSET + | OFFSET_DATETIME + | ON + | ONLY + | OR + | ORDER + | ORDINALITY + | OTHERS + | OVER + | OVERFLOW + | OVERLAY + | PAD + | PATH + | PARTITION + | PASSING + | PERCENT + | PLACING + | POSITION + | PRECEDING + | QUARTER + | RANGE + | RESPECT + | RETURNING + | RIGHT + | ROLLUP + | ROW + | ROWS + | SEARCH + | SECOND + | SELECT + | SET + | SIZE + | SOME + | SUBSTRING + | SUM + | THEN + | TIES + | TIME + | TIMESTAMP + | TIMEZONE_HOUR + | TIMEZONE_MINUTE + | TO + | TRAILING + | TREAT + | TRIM + | TRUNC + | TRUNCATE + | TYPE + | UNBOUNDED + | UNCONDITIONAL + | UNION + | UNIQUE + | UPDATE + | USING + | VALUE + | VALUES + | VERSION + | VERSIONED + | WEEK + | WHEN + | WHERE + | WITH + | WITHIN + | WITHOUT + | WRAPPER + | XML + | XMLAGG + | XMLATTRIBUTES + | XMLELEMENT + | XMLEXISTS + | XMLFOREST + | XMLPI + | XMLQUERY + | XMLTABLE + | YEAR + | ZONED) + ; + identifier - : reservedWord - ; - -character - : CHARACTER - ; - -functionName - : reservedWord ('.' reservedWord)* - ; - -reservedWord - : IDENTIFICATION_VARIABLE - | f=(ALL - | AND - | ANY - | AS - | ASC - | AVG - | BETWEEN - | BOTH - | BREADTH - | BY - | CASE - | CAST - | COLLATE - | COUNT - | CROSS - | CUBE - | CURRENT - | CURRENT_DATE - | CURRENT_INSTANT - | CURRENT_TIME - | CURRENT_TIMESTAMP - | CYCLE - | DATE - | DATETIME - | DAY - | DEFAULT - | DELETE - | DEPTH - | DESC - | DISTINCT - | ELEMENT - | ELEMENTS - | ELSE - | EMPTY - | END - | ENTRY - | EPOCH - | ERROR - | ESCAPE - | EVERY - | EXCEPT - | EXCLUDE - | EXISTS - | EXP - | EXTRACT - | FETCH - | FILTER - | FIRST - | FLOOR - | FOLLOWING - | FOR - | FORMAT - | FROM - | FULL - | FUNCTION - | GROUP - | GROUPS - | HAVING - | HOUR - | ID - | IGNORE - | ILIKE - | IN - | INDEX - | INDICES - | INNER - | INSERT - | INSTANT - | INTERSECT - | INTO - | IS - | JOIN - | KEY - | LAST - | LEADING - | LEFT - | LIKE - | LIMIT - | LIST - | LISTAGG - | LOCAL - | LOCAL_DATE - | LOCAL_DATETIME - | LOCAL_TIME - | MAP - | MATERIALIZED - | MAX - | MAXELEMENT - | MAXINDEX - | MEMBER - | MICROSECOND - | MILLISECOND - | MIN - | MINELEMENT - | MININDEX - | MINUTE - | MONTH - | NANOSECOND - | NATURALID - | NEW - | NEXT - | NO - | NOT - | NULLS - | OBJECT - | OF - | OFFSET - | OFFSET_DATETIME - | ON - | ONLY - | OR - | ORDER - | OTHERS - | OUTER - | OVER - | OVERFLOW - | OVERLAY - | PAD - | PARTITION - | PERCENT - | PLACING - | POSITION - | POWER - | PRECEDING - | QUARTER - | RANGE - | RESPECT - | RIGHT - | ROLLUP - | ROW - | ROWS - | SEARCH - | SECOND - | SELECT - | SET - | SIZE - | SOME - | SUBSTRING - | SUM - | THEN - | TIES - | TIME - | TIMESTAMP - | TIMEZONE_HOUR - | TIMEZONE_MINUTE - | TO - | TRAILING - | TREAT - | TRIM - | TRUNC - | TRUNCATE - | TYPE - | UNBOUNDED - | UNION - | UPDATE - | USING - | VALUE - | VALUES - | VERSION - | VERSIONED - | WEEK - | WHEN - | WHERE - | WITH - | WITHIN - | WITHOUT - | YEAR) - ; + : nakedIdentifier + | FULL + | INNER + | LEFT + | OUTER + | RIGHT + ; /* Lexer rules */ -WS : [ \t\r\n] -> skip ; +WS : [ \t\r\n] -> channel(HIDDEN); +COMMENT : '/*' (~'*' | '*' ~'/' )* '*/' -> skip; // Build up case-insentive tokens @@ -899,14 +1810,22 @@ fragment X: 'x' | 'X'; fragment Y: 'y' | 'Y'; fragment Z: 'z' | 'Z'; +ASTERISK : '*'; + // The following are reserved identifiers: +ID : I D; +VERSION : V E R S I O N; +VERSIONED : V E R S I O N E D; +NATURALID : N A T U R A L I D; +FK : F K; +ABSENT : A B S E N T; ALL : A L L; AND : A N D; ANY : A N Y; +ARRAY : A R R A Y; AS : A S; ASC : A S C; -ASTERISK : '*'; AVG : A V G; BETWEEN : B E T W E E N; BOTH : B O T H; @@ -914,8 +1833,13 @@ BREADTH : B R E A D T H; BY : B Y; CASE : C A S E; CAST : C A S T; -CEILING : C E I L I N G; COLLATE : C O L L A T E; +COLUMN : C O L U M N; +COLUMNS : C O L U M N S; +CONDITIONAL : C O N D I T I O N A L; +CONFLICT : C O N F L I C T; +CONSTRAINT : C O N S T R A I N T; +CONTAINS : C O N T A I N S; COUNT : C O U N T; CROSS : C R O S S; CUBE : C U B E; @@ -933,6 +1857,7 @@ DELETE : D E L E T E; DEPTH : D E P T H; DESC : D E S C; DISTINCT : D I S T I N C T; +DO : D O; ELEMENT : E L E M E N T; ELEMENTS : E L E M E N T S; ELSE : E L S E; @@ -946,14 +1871,10 @@ EVERY : E V E R Y; EXCEPT : E X C E P T; EXCLUDE : E X C L U D E; 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; FILTER : F I L T E R; FIRST : F I R S T; -FK : F K; -FLOOR : F L O O R; FOLLOWING : F O L L O W I N G; FOR : F O R; FORMAT : F O R M A T; @@ -964,20 +1885,31 @@ GROUP : G R O U P; GROUPS : G R O U P S; HAVING : H A V I N G; HOUR : H O U R; -ID : I D; IGNORE : I G N O R E; ILIKE : I L I K E; IN : I N; +INCLUDES : I N C L U D E S; INDEX : I N D E X; INDICES : I N D I C E S; INNER : I N N E R; INSERT : I N S E R T; INSTANT : I N S T A N T; INTERSECT : I N T E R S E C T; +INTERSECTS : I N T E R S E C T S; INTO : I N T O; IS : I S; JOIN : J O I N; +JSON : J S O N; +JSON_ARRAY : J S O N '_' A R R A Y; +JSON_ARRAYAGG : J S O N '_' A R R A Y A G G; +JSON_EXISTS : J S O N '_' E X I S T S; +JSON_OBJECT : J S O N '_' O B J E C T; +JSON_OBJECTAGG : J S O N '_' O B J E C T A G G; +JSON_QUERY : J S O N '_' Q U E R Y; +JSON_TABLE : J S O N '_' T A B L E; +JSON_VALUE : J S O N '_' V A L U E; KEY : K E Y; +KEYS : K E Y S; LAST : L A S T; LATERAL : L A T E R A L; LEADING : L E A D I N G; @@ -986,7 +1918,6 @@ LIKE : L I K E; LIMIT : L I M I T; LIST : L I S T; LISTAGG : L I S T A G G; -LN : L N; LOCAL : L O C A L; LOCAL_DATE : L O C A L '_' D A T E ; LOCAL_DATETIME : L O C A L '_' D A T E T I M E; @@ -1004,13 +1935,14 @@ MINELEMENT : M I N E L E M E N T; MININDEX : M I N I N D E X; MINUTE : M I N U T E; MONTH : M O N T H; +NAME : N A M E; NANOSECOND : N A N O S E C O N D; -NATURALID : N A T U R A L I D; NEW : N E W; +NESTED : N E S T E D; NEXT : N E X T; NO : N O; NOT : N O T; -NULL : N U L L; +NOTHING : N O T H I N G; NULLS : N U L L S; OBJECT : O B J E C T; OF : O F; @@ -1020,21 +1952,24 @@ ON : O N; ONLY : O N L Y; OR : O R; ORDER : O R D E R; +ORDINALITY : O R D I N A L I T Y; OTHERS : O T H E R S; OUTER : O U T E R; OVER : O V E R; OVERFLOW : O V E R F L O W; OVERLAY : O V E R L A Y; PAD : P A D; +PATH : P A T H; PARTITION : P A R T I T I O N; +PASSING : P A S S I N G; PERCENT : P E R C E N T; PLACING : P L A C I N G; POSITION : P O S I T I O N; -POWER : P O W E R; PRECEDING : P R E C E D I N G; QUARTER : Q U A R T E R; RANGE : R A N G E; RESPECT : R E S P E C T; +RETURNING : R E T U R N I N G; RIGHT : R I G H T; ROLLUP : R O L L U P; ROW : R O W; @@ -1057,40 +1992,140 @@ TO : T O; TRAILING : T R A I L I N G; TREAT : T R E A T; TRIM : T R I M; -TRUE : T R U E; TRUNC : T R U N C; TRUNCATE : T R U N C A T E; TYPE : T Y P E; UNBOUNDED : U N B O U N D E D; +UNCONDITIONAL : U N C O N D I T I O N A L; UNION : U N I O N; +UNIQUE : U N I Q U E; UPDATE : U P D A T E; USING : U S I N G; VALUE : V A L U E; VALUES : V A L U E S; -VERSION : V E R S I O N; -VERSIONED : V E R S I O N E D; WEEK : W E E K; WHEN : W H E N; WHERE : W H E R E; WITH : W I T H; WITHIN : W I T H I N; WITHOUT : W I T H O U T; +WRAPPER : W R A P P E R; +XML : X M L; +XMLAGG : X M L A G G; +XMLATTRIBUTES : X M L A T T R I B U T E S; +XMLELEMENT : X M L E L E M E N T; +XMLEXISTS : X M L E X I S T S; +XMLFOREST : X M L F O R E S T; +XMLPI : X M L P I; +XMLQUERY : X M L Q U E R Y; +XMLTABLE : X M L T A B L E; YEAR : Y E A R; +ZONED : Z O N E D; + +NULL : N U L L; +TRUE : T R U E; +FALSE : F A L S E; + +fragment +INTEGER_NUMBER + : DIGIT+ + ; + +fragment +FLOATING_POINT_NUMBER + : DIGIT+ '.' DIGIT* EXPONENT? + | '.' DIGIT+ EXPONENT? + | DIGIT+ EXPONENT + | DIGIT+ + ; + +fragment +EXPONENT : [eE] [+-]? DIGIT+; -fragment INTEGER_NUMBER : ('0' .. '9')+ ; -fragment FLOAT_NUMBER : INTEGER_NUMBER+ '.'? INTEGER_NUMBER* (E [+-]? INTEGER_NUMBER)? ; fragment HEX_DIGIT : [0-9a-fA-F]; +fragment SINGLE_QUOTE : '\''; +fragment DOUBLE_QUOTE : '"'; + +STRING_LITERAL : SINGLE_QUOTE ( SINGLE_QUOTE SINGLE_QUOTE | ~('\'') )* SINGLE_QUOTE; + +JAVA_STRING_LITERAL + : DOUBLE_QUOTE ( ESCAPE_SEQUENCE | ~('"') )* DOUBLE_QUOTE + | [jJ] SINGLE_QUOTE ( ESCAPE_SEQUENCE | ~('\'') )* SINGLE_QUOTE + | [jJ] DOUBLE_QUOTE ( ESCAPE_SEQUENCE | ~('\'') )* DOUBLE_QUOTE + ; + +INTEGER_LITERAL : INTEGER_NUMBER ('_' INTEGER_NUMBER)*; + +LONG_LITERAL : INTEGER_NUMBER ('_' INTEGER_NUMBER)* LONG_SUFFIX; + +FLOAT_LITERAL : FLOATING_POINT_NUMBER FLOAT_SUFFIX; + +DOUBLE_LITERAL : FLOATING_POINT_NUMBER DOUBLE_SUFFIX?; + +BIG_INTEGER_LITERAL : INTEGER_NUMBER BIG_INTEGER_SUFFIX; + +BIG_DECIMAL_LITERAL : FLOATING_POINT_NUMBER BIG_DECIMAL_SUFFIX; + +HEX_LITERAL : '0' [xX] HEX_DIGIT+ LONG_SUFFIX?; -CHARACTER : '\'' (~ ('\'' | '\\' )) '\'' ; -STRINGLITERAL : '\'' ('\'' '\'' | ~('\''))* '\'' ; -JAVASTRINGLITERAL : '"' ( ('\\' [btnfr"']) | ~('"'))* '"'; -INTEGER_LITERAL : INTEGER_NUMBER (L | B I)? ; -FLOAT_LITERAL : FLOAT_NUMBER (D | F | B D)?; -HEXLITERAL : '0' X HEX_DIGIT+ ; BINARY_LITERAL : [xX] '\'' HEX_DIGIT+ '\'' | [xX] '"' HEX_DIGIT+ '"' ; -IDENTIFICATION_VARIABLE : ('a' .. 'z' | 'A' .. 'Z' | '\u0080' .. '\ufffe' | '$' | '_') ('a' .. 'z' | 'A' .. 'Z' | '\u0080' .. '\ufffe' | '0' .. '9' | '$' | '_')* ; +// ESCAPE start tokens +TIMESTAMP_ESCAPE_START : '{ts'; +DATE_ESCAPE_START : '{d'; +TIME_ESCAPE_START : '{t'; + +PLUS : '+'; +MINUS : '-'; + + +fragment +LETTER : [a-zA-Z\u0080-\ufffe_$]; + +fragment +DIGIT : [0-9]; + +fragment +LONG_SUFFIX : [lL]; + +fragment +FLOAT_SUFFIX : [fF]; + +fragment +DOUBLE_SUFFIX : [dD]; + +fragment +BIG_DECIMAL_SUFFIX : [bB] [dD]; + +fragment +BIG_INTEGER_SUFFIX : [bB] [iI]; + +// Identifiers +IDENTIFIER + : LETTER (LETTER | DIGIT)* + ; + +fragment +BACKTICK : '`'; + +fragment BACKSLASH : '\\'; + +fragment +UNICODE_ESCAPE + : 'u' HEX_DIGIT HEX_DIGIT HEX_DIGIT HEX_DIGIT + ; + +fragment +ESCAPE_SEQUENCE + : BACKSLASH [btnfr"'] + | BACKSLASH UNICODE_ESCAPE + | BACKSLASH BACKSLASH + ; + +QUOTED_IDENTIFIER + : BACKTICK ( ESCAPE_SEQUENCE | '\\' BACKTICK | ~([`]) )* BACKTICK + ; 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 a7f319b793..5c8730f523 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,18 @@ 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)? # SelectQuery + | from_clause (where_clause)? (groupby_clause)? (having_clause)? (orderby_clause)? (set_fuction)? # FromQuery + ; + +setOperator + : UNION ALL? + | INTERSECT ALL? + | EXCEPT ALL? + ; + +set_fuction + : setOperator select_statement ; update_statement @@ -62,6 +73,7 @@ from_clause identificationVariableDeclarationOrCollectionMemberDeclaration : identification_variable_declaration | collection_member_declaration + | '(' subquery ')' (AS? identification_variable)? ; identification_variable_declaration @@ -69,15 +81,15 @@ identification_variable_declaration ; range_variable_declaration - : entity_name AS? identification_variable + : entity_name (AS? identification_variable)? ; join - : join_spec join_association_path_expression AS? identification_variable (join_condition)? + : join_spec join_association_path_expression (AS? identification_variable)? (join_condition)? ; fetch_join - : join_spec FETCH join_association_path_expression + : join_spec FETCH join_association_path_expression (AS? identification_variable)? join_condition? ; join_spec @@ -96,15 +108,15 @@ join_association_path_expression ; join_collection_valued_path_expression - : identification_variable '.' (single_valued_embeddable_object_field '.')* collection_valued_field + : (identification_variable '.')? (single_valued_embeddable_object_field '.')* collection_valued_field ; join_single_valued_path_expression - : identification_variable '.' (single_valued_embeddable_object_field '.')* single_valued_object_field + : (identification_variable '.')? (single_valued_embeddable_object_field '.')* single_valued_object_field ; collection_member_declaration - : IN '(' collection_valued_path_expression ')' AS? identification_variable + : IN '(' collection_valued_path_expression ')' (AS? identification_variable)? ; qualified_identification_variable @@ -208,8 +220,8 @@ constructor_item ; aggregate_expression - : (AVG | MAX | MIN | SUM) '(' (DISTINCT)? state_valued_path_expression ')' - | COUNT '(' (DISTINCT)? (identification_variable | state_valued_path_expression | single_valued_object_path_expression) ')' + : (AVG | MAX | MIN | SUM) '(' (DISTINCT)? simple_select_expression ')' + | COUNT '(' (DISTINCT)? simple_select_expression ')' | function_invocation ; @@ -234,9 +246,19 @@ orderby_clause : ORDER BY orderby_item (',' orderby_item)* ; -// TODO Error in spec BNF, correctly shown elsewhere in spec. orderby_item - : (state_field_path_expression | general_identification_variable | result_variable ) (ASC | DESC)? + : orderby_expression (ASC | DESC)? nullsPrecedence? + ; + +orderby_expression + : state_field_path_expression + | general_identification_variable + | string_expression + | scalar_expression + ; + +nullsPrecedence + : NULLS (FIRST | LAST) ; subquery @@ -249,7 +271,7 @@ subquery_from_clause subselect_identification_variable_declaration : identification_variable_declaration - | derived_path_expression AS? identification_variable (join)* + | derived_path_expression (AS? identification_variable)? (join)* | derived_collection_member_declaration ; @@ -333,12 +355,17 @@ between_expression ; in_expression - : (state_valued_path_expression | type_discriminator) (NOT)? IN (('(' in_item (',' in_item)* ')') | ( '(' subquery ')') | collection_valued_input_parameter) + : (string_expression | type_discriminator) (NOT)? IN (('(' in_item (',' in_item)* ')') | ( '(' subquery ')') | collection_valued_input_parameter) ; in_item : literal + | string_expression + | boolean_literal + | numeric_literal + | date_time_timestamp_literal | single_valued_input_parameter + | conditional_expression ; like_expression @@ -378,13 +405,15 @@ all_or_any_expression ; comparison_expression - : string_expression comparison_operator (string_expression | all_or_any_expression) - | boolean_expression op=(EQUAL | NOT_EQUAL) (boolean_expression | all_or_any_expression) - | enum_expression op=(EQUAL | NOT_EQUAL) (enum_expression | all_or_any_expression) - | datetime_expression comparison_operator (datetime_expression | all_or_any_expression) - | entity_expression op=(EQUAL | NOT_EQUAL) (entity_expression | all_or_any_expression) - | arithmetic_expression comparison_operator (arithmetic_expression | all_or_any_expression) - | entity_type_expression op=(EQUAL | NOT_EQUAL) entity_type_expression + : string_expression comparison_operator (string_expression | all_or_any_expression) #StringComparison + | boolean_expression op=(EQUAL | NOT_EQUAL) (boolean_expression | all_or_any_expression) #BooleanComparison + | boolean_expression #DirectBooleanCheck + | enum_expression op=(EQUAL | NOT_EQUAL) (enum_expression | all_or_any_expression) #EnumComparison + | datetime_expression comparison_operator (datetime_expression | all_or_any_expression) #DatetimeComparison + | entity_expression op=(EQUAL | NOT_EQUAL) (entity_expression | all_or_any_expression) #EntityComparison + | arithmetic_expression comparison_operator (arithmetic_expression | all_or_any_expression) #ArithmeticComparison + | entity_type_expression op=(EQUAL | NOT_EQUAL) entity_type_expression #EntityTypeComparison + | string_expression REGEXP string_literal #RegexpComparison ; comparison_operator @@ -418,6 +447,8 @@ arithmetic_primary | functions_returning_numerics | aggregate_expression | case_expression + | arithmetic_cast_function + | type_cast_function | function_invocation | '(' subquery ')' ; @@ -430,7 +461,10 @@ string_expression | aggregate_expression | case_expression | function_invocation + | string_cast_function + | type_cast_function | '(' subquery ')' + | string_expression '||' string_expression ; datetime_expression @@ -515,6 +549,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 @@ -523,6 +560,17 @@ trim_specification | BOTH ; +arithmetic_cast_function + : CAST '(' string_expression (AS)? f=(INTEGER|LONG|FLOAT|DOUBLE) ')' + ; + +type_cast_function + : CAST '(' scalar_expression (AS)? identification_variable ('(' numeric_literal (',' numeric_literal)* ')')? ')' + ; + +string_cast_function + : CAST '(' scalar_expression (AS)? STRING ')' + ; function_invocation : FUNCTION '(' function_name (',' function_arg)* ')' @@ -545,10 +593,7 @@ datetime_part ; function_arg - : literal - | state_valued_path_expression - | input_parameter - | scalar_expression + : simple_select_expression ; case_expression @@ -587,6 +632,7 @@ nullif_expression : NULLIF '(' scalar_expression ',' scalar_expression ')' ; + /******************* Gaps in the spec. *******************/ @@ -599,6 +645,7 @@ trim_character identification_variable : IDENTIFICATION_VARIABLE | f=(COUNT + | AS | DATE | FROM | INNER @@ -608,11 +655,13 @@ identification_variable | ORDER | OUTER | POWER + | RIGHT | FLOOR | SIGN | TIME | TYPE | VALUE) + | type_literal ; constructor_name @@ -640,6 +689,9 @@ pattern_value date_time_timestamp_literal : STRINGLITERAL + | DATELITERAL + | TIMELITERAL + | TIMESTAMPLITERAL ; entity_type_literal @@ -648,7 +700,8 @@ entity_type_literal escape_character : CHARACTER - | character_valued_input_parameter // + | string_literal + | character_valued_input_parameter ; numeric_literal @@ -657,6 +710,14 @@ numeric_literal | LONGLITERAL ; +type_literal + : STRING + | INTEGER + | LONG + | FLOAT + | DOUBLE + ; + boolean_literal : TRUE | FALSE @@ -681,18 +742,22 @@ subtype collection_valued_field : identification_variable + | reserved_word ; single_valued_object_field : identification_variable + | reserved_word ; state_field : identification_variable + | reserved_word ; collection_value_field : identification_variable + | reserved_word ; entity_name @@ -737,6 +802,7 @@ reserved_word |BOTH |BY |CASE + |CAST |CEILING |COALESCE |CONCAT @@ -788,6 +854,8 @@ reserved_word |ORDER |OUTER |POWER + |REPLACE + |RIGHT |ROUND |SELECT |SET @@ -813,7 +881,8 @@ reserved_word */ -WS : [ \t\r\n] -> skip ; +WS : [ \t\r\n] -> channel(HIDDEN) ; +COMMENT : '/*' (~'*' | '*' ~'/' )* '*/' -> skip; // Build up case-insentive tokens @@ -857,6 +926,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; @@ -869,16 +939,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; @@ -887,9 +961,12 @@ 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; +LAST : L A S T; LEADING : L E A D I N G; LEFT : L E F T; LENGTH : L E N G T H; @@ -897,6 +974,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; @@ -906,6 +984,7 @@ NEW : N E W; NOT : N O T; NULL : N U L L; NULLIF : N U L L I F; +NULLS : N U L L S; OBJECT : O B J E C T; OF : O F; ON : O N; @@ -913,6 +992,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; @@ -920,6 +1002,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; @@ -929,6 +1012,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; @@ -940,8 +1024,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 fc47f221ce..10001ec8c8 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 @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. @@ -18,6 +18,7 @@ import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Expression; import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; @@ -33,13 +34,15 @@ 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; import org.springframework.data.domain.ExampleMatcher.PropertyValueTransformer; 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.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.ObjectUtils; @@ -57,6 +60,7 @@ * @author Oliver Gierke * @author Jens Schauder * @author Greg Turnquist + * @author Arnaud Lecointre * @since 1.10 */ public class QueryByExamplePredicateBuilder { @@ -78,8 +82,7 @@ public class QueryByExamplePredicateBuilder { * @param example must not be {@literal null}. * @return {@literal null} indicates no {@link Predicate}. */ - @Nullable - public static Predicate getPredicate(Root root, CriteriaBuilder cb, Example example) { + public static @Nullable Predicate getPredicate(Root root, CriteriaBuilder cb, Example example) { return getPredicate(root, cb, example, EscapeCharacter.DEFAULT); } @@ -92,8 +95,7 @@ public static Predicate getPredicate(Root root, CriteriaBuilder cb, Examp * @param escapeCharacter Must not be {@literal null}. * @return {@literal null} indicates no constraints */ - @Nullable - public static Predicate getPredicate(Root root, CriteriaBuilder cb, Example example, + public static @Nullable Predicate getPredicate(Root root, CriteriaBuilder cb, Example example, EscapeCharacter escapeCharacter) { Assert.notNull(root, "Root must not be null"); @@ -103,8 +105,8 @@ public static Predicate getPredicate(Root root, CriteriaBuilder cb, Examp ExampleMatcher matcher = example.getMatcher(); List predicates = getPredicates("", cb, root, root.getModel(), example.getProbe(), - example.getProbeType(), new ExampleMatcherAccessor(matcher), new PathNode("root", null, example.getProbe()), - escapeCharacter); + example.getProbeType(), matcher.getMatchMode(), new ExampleMatcherAccessor(matcher), + new PathNode("root", null, example.getProbe()), escapeCharacter); if (predicates.isEmpty()) { return null; @@ -121,7 +123,7 @@ public static Predicate getPredicate(Root root, CriteriaBuilder cb, Examp @SuppressWarnings({ "rawtypes", "unchecked" }) static List getPredicates(String path, CriteriaBuilder cb, Path from, ManagedType type, Object value, - Class probeType, ExampleMatcherAccessor exampleAccessor, PathNode currentNode, + Class probeType, MatchMode matchMode, ExampleMatcherAccessor exampleAccessor, PathNode currentNode, EscapeCharacter escapeCharacter) { List predicates = new ArrayList<>(); @@ -139,7 +141,7 @@ static List getPredicates(String path, CriteriaBuilder cb, Path fr Optional optionalValue = transformer .apply(Optional.ofNullable(beanWrapper.getPropertyValue(attribute.getName()))); - if (!optionalValue.isPresent()) { + if (optionalValue.isEmpty()) { if (exampleAccessor.getNullHandler().equals(ExampleMatcher.NullHandler.INCLUDE)) { predicates.add(cb.isNull(from.get(attribute))); @@ -158,7 +160,7 @@ static List getPredicates(String path, CriteriaBuilder cb, Path fr predicates .addAll(getPredicates(currentPath, cb, from.get(attribute.getName()), (ManagedType) attribute.getType(), - attributeValue, probeType, exampleAccessor, currentNode, escapeCharacter)); + attributeValue, probeType, matchMode, exampleAccessor, currentNode, escapeCharacter)); continue; } @@ -171,8 +173,10 @@ static List getPredicates(String path, CriteriaBuilder cb, Path fr ClassUtils.getShortName(probeType), node)); } - predicates.addAll(getPredicates(currentPath, cb, ((From) from).join(attribute.getName()), - (ManagedType) attribute.getType(), attributeValue, probeType, exampleAccessor, node, escapeCharacter)); + JoinType joinType = matchMode.equals(MatchMode.ALL) ? JoinType.INNER : JoinType.LEFT; + predicates.addAll(getPredicates(currentPath, cb, ((From) from).join(attribute.getName(), joinType), + (ManagedType) attribute.getType(), attributeValue, probeType, matchMode, exampleAccessor, node, + escapeCharacter)); continue; } @@ -238,7 +242,6 @@ private static class PathNode { String name; @Nullable PathNode parent; - List siblings = new ArrayList<>(); @Nullable Object value; PathNode(String edge, @Nullable PathNode parent, @Nullable Object value) { @@ -250,9 +253,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 8f19eb580f..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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 46384cb1bd..1764eb86b3 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -15,19 +15,18 @@ */ package org.springframework.data.jpa.domain; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; + 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 jakarta.persistence.ManyToOne; -import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.Temporal; -import jakarta.persistence.TemporalType; - 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,22 +38,19 @@ * @param 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 extends AbstractPersistable implements Auditable { - private static final long serialVersionUID = 141481953116476081L; - @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 getCreatedBy() { @@ -62,19 +58,19 @@ public Optional getCreatedBy() { } @Override - public void setCreatedBy(U createdBy) { + public void setCreatedBy(@Nullable U createdBy) { this.createdBy = createdBy; } @Override public Optional 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 @@ -83,18 +79,18 @@ public Optional getLastModifiedBy() { } @Override - public void setLastModifiedBy(U lastModifiedBy) { + public void setLastModifiedBy(@Nullable U lastModifiedBy) { this.lastModifiedBy = lastModifiedBy; } @Override public Optional 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 63fa8307e6..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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 @@ -34,14 +35,16 @@ * @author Thomas Darimont * @author Mark Paluch * @author Greg Turnquist + * @author Ngoc Nhan * @param 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 implements Persistable { - @Id @GeneratedValue private @Nullable PK id; - @Nullable + @Id @GeneratedValue private PK id; + @Override public PK getId() { return id; @@ -73,7 +76,7 @@ public String toString() { } @Override - public boolean equals(Object obj) { + public boolean equals(@Nullable Object obj) { if (null == obj) { return false; @@ -89,7 +92,7 @@ public boolean equals(Object obj) { AbstractPersistable that = (AbstractPersistable) obj; - return null == this.getId() ? false : this.getId().equals(that.getId()); + return this.getId() != null && this.getId().equals(that.getId()); } @Override 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..598797a984 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/DeleteSpecification.java @@ -0,0 +1,247 @@ +/* + * 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.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. + *

+ * 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}, such as {@link #unrestricted()}, are + * considered to not contribute to the overall predicate, and their result is not considered in the final predicate. + * + * @param the type of the {@link Root entity} to which the specification is applied. + * @author Mark Paluch + * @author Peter Aisher + * @since 4.0 + */ +@FunctionalInterface +public interface DeleteSpecification extends Serializable { + + /** + * Simple static factory method to create a specification which does not participate in matching. The specification + * returned is {@code null}-like, and is elided in all operations. + * + *

+	 * unrestricted().and(other) // consider only `other`
+	 * unrestricted().or(other) // consider only `other`
+	 * not(unrestricted()) // equivalent to `unrestricted()`
+	 * 
+ * + * @param the type of the {@link Root} the resulting {@literal DeleteSpecification} operates on. + * @return guaranteed to be not {@literal null}. + */ + static DeleteSpecification unrestricted() { + return (root, query, builder) -> null; + } + + /** + * Simple static factory method to add some syntactic sugar around a {@literal DeleteSpecification}. + * + * @param 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 DeleteSpecification where(DeleteSpecification 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 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 DeleteSpecification where(PredicateSpecification 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 and(DeleteSpecification 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 and(PredicateSpecification 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 or(DeleteSpecification 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 or(PredicateSpecification other) { + + Assert.notNull(other, "Other specification must not be null"); + + return SpecificationComposition.composed(this, where(other), CriteriaBuilder::or); + } + + /** + * Negates the given {@link DeleteSpecification}. + * + * @param 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 DeleteSpecification not(DeleteSpecification 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) : null; + }; + } + + /** + * Applies an AND operation to all the given {@link DeleteSpecification}s. If {@code specifications} is empty, the + * resulting {@link DeleteSpecification} will be {@link #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 DeleteSpecification allOf(DeleteSpecification... 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 {@link #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 DeleteSpecification allOf(Iterable> 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 {@link #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 DeleteSpecification anyOf(DeleteSpecification... 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 {@link #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 DeleteSpecification anyOf(Iterable> 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 root, CriteriaDelete 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 a844135fd0..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -18,31 +18,39 @@ 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; import java.util.Collection; 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. + *

+ * {@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 * @author Christoph Strobl * @author David Madden * @author Jens Schauder + * @author Ngoc Nhan */ public class JpaSort extends Sort { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; private JpaSort(Direction direction, List> paths) { - this(Collections.emptyList(), direction, paths); + this(Collections. emptyList(), direction, paths); } private JpaSort(List orders, @Nullable Direction direction, List> paths) { @@ -74,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) { @@ -85,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)); @@ -94,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"); @@ -109,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"); @@ -128,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"); @@ -146,7 +160,7 @@ public JpaSort andUnsafe(@Nullable Direction direction, String... properties) { orders.add(new JpaOrder(direction, property)); } - return new JpaSort(orders, direction, Collections.>emptyList()); + return new JpaSort(orders, direction, Collections.> emptyList()); } /** @@ -217,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 */ @@ -233,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 */ @@ -269,6 +283,8 @@ private Path(List> attributes) { * @param attribute must not be {@literal null}. * @return */ + @Contract("_ -> new") + @CheckReturnValue public , U> Path dot(A attribute) { return new Path<>(add(attribute)); } @@ -279,15 +295,17 @@ public , U> Path dot(A attribute) { * @param attribute must not be {@literal null}. * @return */ + @Contract("_ -> new") + @CheckReturnValue public

, U> Path dot(P attribute) { - return new Path(add(attribute)); + return new Path<>(add(attribute)); } private List> add(Attribute attribute) { Assert.notNull(attribute, "Attribute must not be null"); - List> newAttributes = new ArrayList>(attributes.size() + 1); + List> newAttributes = new ArrayList<>(attributes.size() + 1); newAttributes.addAll(attributes); newAttributes.add(attribute); return newAttributes; @@ -302,7 +320,7 @@ public String toString() { builder.append(attribute.getName()).append("."); } - return builder.length() == 0 ? "" : builder.substring(0, builder.lastIndexOf(".")); + return builder.isEmpty() ? "" : builder.substring(0, builder.lastIndexOf(".")); } } @@ -316,7 +334,7 @@ public String toString() { */ public static class JpaOrder extends Order { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; private final boolean unsafe; @@ -325,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); @@ -335,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) { @@ -344,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; @@ -366,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..318cf7c580 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/PredicateSpecification.java @@ -0,0 +1,203 @@ +/* + * 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.domain; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.From; +import jakarta.persistence.criteria.Predicate; + +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. + *

+ * 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)} with reduced type interference of the query source type. + *

+ * PredicateSpecifications are building blocks for composition and do not express their type opinion towards a specific + * entity source or join source type for improved reuse. + *

+ * Composition considers whether one or more specifications contribute to the overall predicate by returning a + * {@link Predicate} or {@literal null}. Specifications returning {@literal null}, such as {@link #unrestricted()}, are + * considered to not contribute to the overall predicate, and their result is not considered in the final predicate. + * + * @param the type of the {@link From From target} to which the specification is applied. + * @author Mark Paluch + * @author Peter Aisher + * @since 4.0 + */ +@FunctionalInterface +public interface PredicateSpecification extends Serializable { + + /** + * Simple static factory method to create a specification which does not participate in matching. The specification + * returned is {@code null}-like, and is elided in all operations. + * + *

+	 * unrestricted().and(other) // consider only `other`
+	 * unrestricted().or(other) // consider only `other`
+	 * not(unrestricted()) // equivalent to `unrestricted()`
+	 * 
+ * + * @param the type of the {@link From} the resulting {@literal PredicateSpecification} operates on. + * @return guaranteed to be not {@literal null}. + */ + static PredicateSpecification unrestricted() { + return (from, builder) -> null; + } + + /** + * Simple static factory method to add some syntactic sugar around a {@literal PredicateSpecification}. + * + * @param the type of the {@link From} the resulting {@literal PredicateSpecification} operates on. + * @param spec must not be {@literal null}. + * @return guaranteed to be not {@literal null}. + * @since 2.0 + */ + static PredicateSpecification where(PredicateSpecification 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 and(PredicateSpecification 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 or(PredicateSpecification other) { + + Assert.notNull(other, "Other specification must not be null"); + + return SpecificationComposition.composed(this, other, CriteriaBuilder::or); + } + + /** + * Negates the given {@link PredicateSpecification}. + * + * @param the type of the {@link From} the resulting {@literal PredicateSpecification} operates on. + * @param spec can be {@literal null}. + * @return guaranteed to be not {@literal null}. + */ + static PredicateSpecification not(PredicateSpecification spec) { + + Assert.notNull(spec, "Specification must not be null"); + + return (from, builder) -> { + + Predicate predicate = spec.toPredicate(from, builder); + return predicate != null ? builder.not(predicate) : null; + }; + } + + /** + * Applies an AND operation to all the given {@link PredicateSpecification}s. If {@code specifications} is empty, the + * resulting {@link PredicateSpecification} will be {@link #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 PredicateSpecification allOf(PredicateSpecification... 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 {@link #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 PredicateSpecification allOf(Iterable> 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 {@link #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 PredicateSpecification anyOf(PredicateSpecification... 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 {@link #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 PredicateSpecification anyOf(Iterable> 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 From} and {@link CriteriaBuilder}. + * + * @param from must not be {@literal null}. + * @param criteriaBuilder must not be {@literal null}. + * @return a {@link Predicate}, may be {@literal null}. + */ + @Nullable + Predicate toPredicate(From from, 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 635cd0d195..4e17dba2d5 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -17,6 +17,7 @@ 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; @@ -24,11 +25,23 @@ 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. + *

+ * 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}, such as {@link #unrestricted()}, are + * considered to not contribute to the overall predicate, and their result is not considered in the final predicate. * + * @param the type of the {@link Root entity} to which the specification is applied. * @author Oliver Gierke * @author Thomas Darimont * @author Krzysztof Rzymkowski @@ -37,87 +50,152 @@ * @author Jens Schauder * @author Daniel Shuy * @author Sergey Rukin + * @author Heeeun Cho + * @author Peter Aisher */ +@FunctionalInterface public interface Specification extends Serializable { - long serialVersionUID = 1L; + /** + * Simple static factory method to create a specification which does not participate in matching. The specification + * returned is {@code null}-like, and is elided in all operations. + * + *

+	 * unrestricted().and(other) // consider only `other`
+	 * unrestricted().or(other) // consider only `other`
+	 * not(unrestricted()) // equivalent to `unrestricted()`
+	 * 
+ * + * @param the type of the {@link Root} the resulting {@literal Specification} operates on. + * @return guaranteed to be not {@literal null}. + * @since 4.0 + */ + static Specification unrestricted() { + return (root, query, builder) -> null; + } /** - * Negates the given {@link Specification}. + * Simple static factory method to add some syntactic sugar around a {@link Specification}. * + * @implNote does not accept {@literal null} values since 4.0, use {@link #unrestricted()} instead of passing + * {@literal null} values. * @param 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 Specification not(@Nullable Specification spec) { + static Specification where(Specification spec) { + + Assert.notNull(spec, "Specification must not be null"); - return spec == null // - ? (root, query, builder) -> null // - : (root, query, builder) -> builder.not(spec.toPredicate(root, query, builder)); + return spec; } /** - * 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 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 */ - static Specification where(@Nullable Specification spec) { - return spec == null ? (root, query, builder) -> null : spec; + static Specification where(PredicateSpecification 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 and(@Nullable Specification other) { + @Contract("_ -> new") + @CheckReturnValue + default Specification and(Specification 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 and(PredicateSpecification 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 or(@Nullable Specification other) { + @Contract("_ -> new") + @CheckReturnValue + default Specification or(Specification 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 must not be {@literal null}. - * @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 root, CriteriaQuery query, CriteriaBuilder criteriaBuilder); + @Contract("_ -> new") + @CheckReturnValue + default Specification or(PredicateSpecification 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 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 Specification allOf(Iterable> specifications) { + static Specification not(Specification 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) : null; + }; } /** + * Applies an AND operation to all the given {@link Specification}s. If {@code specifications} is empty, the resulting + * {@link Specification} will be {@link #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 */ @@ -127,20 +205,28 @@ static Specification allOf(Specification... 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 {@link #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 Specification anyOf(Iterable> specifications) { + static Specification allOf(Iterable> 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 {@link #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 */ @@ -148,4 +234,33 @@ static Specification anyOf(Iterable> specifications) { static Specification anyOf(Specification... 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 {@link #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 Specification anyOf(Iterable> 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 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 f708fcef4b..3b6c9e1273 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 @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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,14 +15,18 @@ */ 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.From; 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. @@ -31,13 +35,14 @@ * @author Oliver Gierke * @author Jens Schauder * @author Mark Paluch - * @see Specification * @since 2.2 + * @see Specification */ 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 Specification composed(@Nullable Specification lhs, @Nullable Specification rhs, @@ -56,9 +61,76 @@ static Specification composed(@Nullable Specification lhs, @Nullable S }; } - @Nullable - private static Predicate toPredicate(@Nullable Specification specification, Root root, CriteriaQuery query, - CriteriaBuilder builder) { + private static @Nullable Predicate toPredicate(@Nullable Specification specification, Root root, + CriteriaQuery query, CriteriaBuilder builder) { return specification == null ? null : specification.toPredicate(root, query, builder); } + + @Contract("_, _, !null -> new") + @SuppressWarnings("NullAway") + static DeleteSpecification composed(@Nullable DeleteSpecification lhs, @Nullable DeleteSpecification 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 @Nullable Predicate toPredicate(@Nullable DeleteSpecification specification, Root root, + @Nullable CriteriaDelete delete, CriteriaBuilder builder) { + + return specification == null || delete == null ? null : specification.toPredicate(root, delete, builder); + } + + static UpdateSpecification composed(@Nullable UpdateSpecification lhs, @Nullable UpdateSpecification 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 @Nullable Predicate toPredicate(@Nullable UpdateSpecification specification, Root root, + CriteriaUpdate update, CriteriaBuilder builder) { + return specification == null ? null : specification.toPredicate(root, update, builder); + } + + static PredicateSpecification composed(PredicateSpecification lhs, PredicateSpecification 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 @Nullable Predicate toPredicate(@Nullable PredicateSpecification specification, From from, + CriteriaBuilder builder) { + return specification == null ? null : specification.toPredicate(from, 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..7299b5abc5 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/UpdateSpecification.java @@ -0,0 +1,350 @@ +/* + * 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.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. + *

+ * 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}, such as {@link #unrestricted()}, are + * considered to not contribute to the overall predicate, and their result is not considered in the final predicate. + * + * @param the type of the {@link Root entity} to which the specification is applied. + * @author Mark Paluch + * @author Peter Aisher + * @since 4.0 + */ +@FunctionalInterface +public interface UpdateSpecification extends Serializable { + + /** + * Simple static factory method to create a specification which does not participate in matching. The specification + * returned is {@code null}-like, and is elided in all operations. + * + *

+	 * unrestricted().and(other) // consider only `other`
+	 * unrestricted().or(other) // consider only `other`
+	 * not(unrestricted()) // equivalent to `unrestricted()`
+	 * 
+ * + * @param the type of the {@link Root} the resulting {@literal UpdateSpecification} operates on. + * @return guaranteed to be not {@literal null}. + */ + static UpdateSpecification unrestricted() { + return (root, query, builder) -> null; + } + + /** + * Simple static factory method to add some syntactic sugar around a {@literal UpdateOperation}. For example: + * + *
+	 * UpdateSpecification<User> updateLastname = UpdateOperation
+	 * 		.<User> update((root, update, criteriaBuilder) -> update.set("lastname", "Heisenberg"))
+	 * 		.where(userHasFirstname("Walter").and(userHasLastname("White")));
+	 *
+	 * repository.update(updateLastname);
+	 * 
+ * + * @param 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 UpdateOperation update(UpdateOperation 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 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 UpdateSpecification where(UpdateSpecification 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 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 UpdateSpecification where(PredicateSpecification 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 and(UpdateSpecification 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 and(PredicateSpecification 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 or(UpdateSpecification 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 or(PredicateSpecification other) { + + Assert.notNull(other, "Other specification must not be null"); + + return SpecificationComposition.composed(this, where(other), CriteriaBuilder::or); + } + + /** + * Negates the given {@link UpdateSpecification}. + * + * @param 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 UpdateSpecification not(UpdateSpecification 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) : null; + }; + } + + /** + * Applies an AND operation to all the given {@link UpdateSpecification}s. If {@code specifications} is empty, the + * resulting {@link UpdateSpecification} will be {@link #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 UpdateSpecification allOf(UpdateSpecification... 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 {@link #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 UpdateSpecification allOf(Iterable> 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 {@link #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 UpdateSpecification anyOf(UpdateSpecification... 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 {@link #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 UpdateSpecification anyOf(Iterable> 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 root, CriteriaUpdate 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: + * + *
+	 * UpdateSpecification<User> updateLastname = UpdateSpecification
+	 * 		.<User> update((root, update, criteriaBuilder) -> update.set("lastname", "Heisenberg"))
+	 * 		.where(userHasFirstname("Walter").and(userHasLastname("White")));
+	 *
+	 * repository.update(updateLastname);
+	 * 
+ * + * @param + */ + @FunctionalInterface + interface UpdateOperation { + + /** + * 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 and(UpdateOperation 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 where(PredicateSpecification 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 where(UpdateSpecification 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 root, CriteriaUpdate 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/AuditingBeanFactoryPostProcessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingBeanFactoryPostProcessor.java index 4d77cb4070..1b669e0854 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingBeanFactoryPostProcessor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingBeanFactoryPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. 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 84de19a961..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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 6335aec76d..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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -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; /** @@ -54,7 +55,6 @@ public class JpaMetamodelMappingContext */ public JpaMetamodelMappingContext(Set models) { - Assert.notNull(models, "JPA metamodel must not be null"); Assert.notEmpty(models, "JPA metamodel must not be empty"); this.models = new Metamodels(models); @@ -115,8 +115,7 @@ private Metamodels(Set 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()); @@ -167,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/JpaPersistentEntity.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntity.java index daf8c5ab74..90ca19aa71 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntity.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. 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 cd6c1a00fd..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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -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; @@ -58,13 +59,12 @@ public JpaPersistentEntityImpl(TypeInformation information, ProxyIdAccessor p super(information, null); - Assert.notNull(proxyIdAccessor, "ProxyIdAccessor must not be null"); this.proxyIdAccessor = proxyIdAccessor; this.metamodel = metamodel; } @Override - protected JpaPersistentProperty returnPropertyIfBetterIdPropertyCandidateOrNull(JpaPersistentProperty property) { + protected @Nullable JpaPersistentProperty returnPropertyIfBetterIdPropertyCandidateOrNull(JpaPersistentProperty property) { return property.isIdProperty() ? property : null; } @@ -118,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/JpaPersistentProperty.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentProperty.java index 111e4cc0b6..63a9cf3515 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentProperty.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentProperty.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. 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 501d3a6444..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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -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> annotations = new HashSet<>(); - annotations.add(OneToMany.class); - annotations.add(OneToOne.class); - annotations.add(ManyToMany.class); - annotations.add(ManyToOne.class); + Set> 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/CollectionAwareProjectionFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/projection/CollectionAwareProjectionFactory.java index eea31872ff..9b602fc65b 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/projection/CollectionAwareProjectionFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/projection/CollectionAwareProjectionFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. 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 f99e6fdb0e..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 @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. @@ -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("")) { + 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 8f472d814f..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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; @@ -47,7 +48,7 @@ private JpaClassUtils() {} */ public static boolean isEntityManagerOfType(EntityManager em, String type) { - EntityManager entityManagerToUse = em.getDelegate()instanceof EntityManager delegate // + EntityManager entityManagerToUse = em.getDelegate() instanceof EntityManager delegate // ? delegate // : em; @@ -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 915ad42af5..62905fec3c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -19,29 +19,41 @@ 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; import jakarta.persistence.metamodel.SingularAttribute; +import java.lang.reflect.Proxy; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.NoSuchElementException; import java.util.Set; +import java.util.function.LongSupplier; +import java.util.stream.Stream; import org.eclipse.persistence.config.QueryHints; +import org.eclipse.persistence.internal.queries.DatabaseQueryMechanism; +import org.eclipse.persistence.internal.queries.JPQLCallQueryMechanism; import org.eclipse.persistence.jpa.JpaQuery; +import org.eclipse.persistence.queries.DatabaseQuery; import org.eclipse.persistence.queries.ScrollableCursor; 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.aop.framework.AopProxyUtils; +import org.springframework.aop.support.AopUtils; 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; import org.springframework.util.ConcurrentReferenceHashMap; +import org.springframework.util.StringUtils; /** * Enumeration representing persistence providers to be used. @@ -52,26 +64,26 @@ * @author Jens Schauder * @author Greg Turnquist * @author Yuriy Tsarkov + * @author Ariel Morelli Andres */ public enum PersistenceProvider implements QueryExtractor, ProxyIdAccessor, QueryComment { /** * Hibernate persistence provider. - *

- * Since Hibernate 4.3 the location of the HibernateEntityManager moved to the org.hibernate.jpa package. In order to - * support both locations we interpret both classnames as a Hibernate {@code PersistenceProvider}. - * - * @see DATAJPA-444 */ - HIBERNATE(// - Collections.singletonList(HIBERNATE_ENTITY_MANAGER_INTERFACE), // - Collections.singletonList(HIBERNATE_JPA_METAMODEL_TYPE)) { + HIBERNATE(List.of(HIBERNATE_ENTITY_MANAGER_FACTORY_INTERFACE), // + List.of(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,17 +121,58 @@ 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), - Collections.singleton(ECLIPSELINK_JPA_METAMODEL_TYPE)) { + ECLIPSELINK(List.of(ECLIPSELINK_ENTITY_MANAGER_FACTORY_INTERFACE), List.of(ECLIPSELINK_JPA_METAMODEL_TYPE)) { @Override - public String extractQueryString(Query query) { - return ((JpaQuery) query).getDatabaseQuery().getJPQLString(); + public String extractQueryString(Object query) { + + if (query instanceof JpaQuery jpaQuery) { + + DatabaseQuery databaseQuery = jpaQuery.getDatabaseQuery(); + + if (StringUtils.hasText(databaseQuery.getJPQLString())) { + return databaseQuery.getJPQLString(); + } + + if (StringUtils.hasText(databaseQuery.getSQLString())) { + return databaseQuery.getSQLString(); + } + } + + return ""; + } + + @Override + public boolean isNativeQuery(Object query) { + + if (query instanceof JpaQuery jpaQuery) { + + DatabaseQueryMechanism call = jpaQuery.getDatabaseQuery().getQueryMechanism(); + + if (call instanceof JPQLCallQueryMechanism) { + return false; + } + + return true; + } + + return false; } @Override @@ -127,9 +180,8 @@ public boolean shouldUseAccessorFor(Object entity) { return false; } - @Nullable @Override - public Object getIdentifierFrom(Object entity) { + public @Nullable Object getIdentifierFrom(Object entity) { return null; } @@ -147,19 +199,24 @@ 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(List.of(GENERIC_JPA_ENTITY_MANAGER_FACTORY_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 +227,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 { @@ -198,8 +254,8 @@ public String getCommentHintKey() { private static final Collection ALL = List.of(HIBERNATE, ECLIPSELINK, GENERIC_JPA); - static ConcurrentReferenceHashMap, PersistenceProvider> CACHE = new ConcurrentReferenceHashMap<>(); - private final Iterable entityManagerClassNames; + private static final ConcurrentReferenceHashMap, PersistenceProvider> CACHE = new ConcurrentReferenceHashMap<>(); + final Iterable entityManagerFactoryClassNames; private final Iterable metamodelClassNames; private final boolean present; @@ -207,25 +263,17 @@ public String getCommentHintKey() { /** * Creates a new {@link PersistenceProvider}. * - * @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}. + * @param entityManagerFactoryClassNames the names of the provider specific + * {@link jakarta.persistence.EntityManagerFactory} implementations. Must not be {@literal null} or empty. + * @param metamodelClassNames the names of the provider specific {@link Metamodel} implementations. Must not be + * {@literal null} or empty. */ - PersistenceProvider(Iterable entityManagerClassNames, Iterable metamodelClassNames) { + PersistenceProvider(Collection entityManagerFactoryClassNames, Collection metamodelClassNames) { - this.entityManagerClassNames = entityManagerClassNames; + this.entityManagerFactoryClassNames = entityManagerFactoryClassNames; this.metamodelClassNames = metamodelClassNames; - - boolean present = false; - for (String entityManagerClassName : entityManagerClassNames) { - - if (ClassUtils.isPresent(entityManagerClassName, PersistenceProvider.class.getClassLoader())) { - present = true; - break; - } - } - - this.present = present; + this.present = Stream.concat(entityManagerFactoryClassNames.stream(), metamodelClassNames.stream()) + .anyMatch(it -> ClassUtils.isPresent(it, PersistenceProvider.class.getClassLoader())); } /** @@ -241,17 +289,57 @@ private static PersistenceProvider cacheAndReturn(Class type, PersistenceProv } /** - * Determines the {@link PersistenceProvider} from the given {@link EntityManager}. If no special one can be + * Determines the {@link PersistenceProvider} from the given {@link EntityManager} by introspecting + * {@link EntityManagerFactory} via {@link EntityManager#getEntityManagerFactory()}. If no special one can be * determined {@link #GENERIC_JPA} will be returned. + *

+ * This method avoids {@link EntityManager} initialization when using + * {@link org.springframework.orm.jpa.SharedEntityManagerCreator} by accessing + * {@link EntityManager#getEntityManagerFactory()}. * * @param em must not be {@literal null}. * @return will never be {@literal null}. + * @see org.springframework.orm.jpa.SharedEntityManagerCreator */ public static PersistenceProvider fromEntityManager(EntityManager em) { Assert.notNull(em, "EntityManager must not be null"); - Class entityManagerType = em.getDelegate().getClass(); + return fromEntityManagerFactory(em.getEntityManagerFactory()); + } + + /** + * Determines the {@link PersistenceProvider} from the given {@link EntityManagerFactory}. 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}. + * @since 3.5.1 + */ + public static PersistenceProvider fromEntityManagerFactory(EntityManagerFactory emf) { + + Assert.notNull(emf, "EntityManagerFactory must not be null"); + + EntityManagerFactory unwrapped = emf; + + while (Proxy.isProxyClass(unwrapped.getClass()) || AopUtils.isAopProxy(unwrapped)) { + + if (Proxy.isProxyClass(unwrapped.getClass())) { + + Class unwrapTo = Proxy.getInvocationHandler(unwrapped).getClass().getName() + .contains("org.springframework.orm.jpa.") ? null : EntityManagerFactory.class; + unwrapped = unwrapped.unwrap(unwrapTo); + } else if (AopUtils.isAopProxy(unwrapped)) { + unwrapped = (EntityManagerFactory) AopProxyUtils.getSingletonTarget(unwrapped); + } + + if (unwrapped == null) { + throw new IllegalStateException( + "Unwrapping EntityManagerFactory from '%s' failed resulting in null".formatted(emf)); + } + } + + Class entityManagerType = unwrapped.getClass(); PersistenceProvider cachedProvider = CACHE.get(entityManagerType); if (cachedProvider != null) { @@ -259,8 +347,8 @@ public static PersistenceProvider fromEntityManager(EntityManager em) { } for (PersistenceProvider provider : ALL) { - for (String entityManagerClassName : provider.entityManagerClassNames) { - if (isEntityManagerOfType(em, entityManagerClassName)) { + for (String emfClassName : provider.entityManagerFactoryClassNames) { + if (isOfType(unwrapped, emfClassName, unwrapped.getClass().getClassLoader())) { return cacheAndReturn(entityManagerType, provider); } } @@ -334,8 +422,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 +433,19 @@ 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. + * @since 4.0 + */ + public long getResultCount(Query resultQuery, LongSupplier countSupplier) { + return countSupplier.getAsLong(); + } + /** * Holds the PersistenceProvider specific interface names. * @@ -354,13 +454,18 @@ 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_INTERFACE = "org.eclipse.persistence.jpa.JpaEntityManagerFactory"; 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_INTERFACE = "org.hibernate.engine.spi.SessionImplementor"; + String ECLIPSELINK_JPA_METAMODEL_TYPE = "org.eclipse.persistence.internal.jpa.metamodel.MetamodelImpl"; + // needed as Spring only exposes that interface via the EM proxy + String HIBERNATE_ENTITY_MANAGER_FACTORY_INTERFACE = "org.hibernate.SessionFactory"; + String HIBERNATE_ENTITY_MANAGER_INTERFACE = "org.hibernate.Session"; 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 executeQueryWithResultStream(Query jpaQuery) { @@ -416,6 +521,7 @@ public void close() { scrollableResults.close(); } } + } /** @@ -465,5 +571,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 3d1dcdb86d..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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -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 8fdabf0959..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -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 2d84633bf1..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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/EntityGraph.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/EntityGraph.java index 0a40faa247..a6fd6367c4 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/EntityGraph.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/EntityGraph.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaContext.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaContext.java index 3b889e30ac..13eff2d386 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaContext.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaRepository.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaRepository.java index 6be4294d94..4346d853dc 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaRepository.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -15,10 +15,10 @@ */ package org.springframework.data.jpa.repository; -import java.util.List; - import jakarta.persistence.EntityManager; +import java.util.List; + import org.springframework.data.domain.Example; import org.springframework.data.domain.Sort; import org.springframework.data.repository.ListCrudRepository; @@ -38,7 +38,8 @@ * @author Jens Schauder */ @NoRepositoryBean -public interface JpaRepository extends ListCrudRepository, ListPagingAndSortingRepository, QueryByExampleExecutor { +public interface JpaRepository + extends ListCrudRepository, ListPagingAndSortingRepository, QueryByExampleExecutor { /** * Flushes all pending changes to the database. @@ -66,6 +67,8 @@ public interface JpaRepository extends ListCrudRepository, ListPag * Deletes the given entities in a batch which means it will create a single query. This kind of operation leaves JPAs * first level cache and the database out of sync. Consider flushing the {@link EntityManager} before calling this * method. + *

+ * It will also NOT honor cascade semantics of JPA, nor will it emit JPA lifecycle events. * * @param entities entities to be deleted. Must not be {@literal null}. * @deprecated Use {@link #deleteAllInBatch(Iterable)} instead. @@ -80,8 +83,8 @@ default void deleteInBatch(Iterable entities) { * first level cache and the database out of sync. Consider flushing the {@link EntityManager} before calling this * method. *

- * It will also NOT honor cascade semantics of JPA, nor will it emit JPA lifecycle events. - *

+ * It will also NOT honor cascade semantics of JPA, nor will it emit JPA lifecycle events. + * * @param entities entities to be deleted. Must not be {@literal null}. * @since 2.5 */ 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 f04544f958..7f11a8500a 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -15,18 +15,22 @@ */ 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; /** @@ -36,23 +40,54 @@ * @author Christoph Strobl * @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 { + /** + * 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 findOne(PredicateSpecification 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 findOne(Specification 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 findAll(PredicateSpecification spec) { + return findAll(Specification.where(spec)); + } + /** * Returns all entities matching the given {@link Specification}. * * @param spec must not be {@literal null}. * @return never {@literal null}. + * @see Specification#unrestricted() */ List findAll(Specification spec); @@ -62,61 +97,204 @@ public interface JpaSpecificationExecutor { * @param spec must not be {@literal null}. * @param pageable must not be {@literal null}. * @return never {@literal null}. + * @see Specification#unrestricted() */ Page findAll(Specification spec, Pageable pageable); + /** + * Returns a {@link Page} of entities matching the given {@link Specification}. + *

+ * Supports counting the total number of entities matching the {@link Specification}. + * + * @param spec can be {@literal null}, if no {@link Specification} is given all entities matching {@code } will be + * selected. + * @param countSpec can be {@literal null},if no {@link Specification} is given all entities matching {@code } will + * be counted. + * @param pageable must not be {@literal null}. + * @return never {@literal null}. + * @since 3.5 + */ + Page findAll(@Nullable Specification spec, @Nullable Specification countSpec, Pageable pageable); + /** * Returns all entities matching the given {@link Specification} and {@link Sort}. * * @param spec must not be {@literal null}. * @param sort must not be {@literal null}. * @return never {@literal null}. + * @see Specification#unrestricted() */ List findAll(Specification 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 spec) { + return count(Specification.where(spec)); + } + /** * Returns the number of instances that the given {@link Specification} will return. * * @param spec the {@link Specification} to count instances for, must not be {@literal null}. * @return the number of instances. + * @see Specification#unrestricted() */ long count(Specification 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 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 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. + *

+ * 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 spec); + + /** + * Deletes by the {@link PredicateSpecification} and returns the number of rows 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 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 spec) { + return delete(DeleteSpecification.where(spec)); + } + + /** + * Deletes by the {@link UpdateSpecification} and returns the number of rows deleted. *

- * 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}. + * 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, must 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(Specification spec); + long delete(DeleteSpecification spec); /** * Returns entities matching the given {@link Specification} applying the {@code queryFunction} that defines the query * and its result type. + *

+ * 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 R findBy(PredicateSpecification spec, + Function, R> queryFunction) { + return findBy(Specification.where(spec), queryFunction); + } + + /** + * Returns entities matching the given {@link Specification} applying the {@code queryFunction} that defines the query + * and its result type. + *

+ * 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 specification. * @since 3.0 + * @throws InvalidDataAccessApiUsageException if the query function returns the {@link FluentQuery} instance. */ - R findBy(Specification spec, Function, R> queryFunction); + R findBy(Specification spec, + Function, R> queryFunction); + + /** + * Extension to {@link FetchableFluentQuery} allowing slice results and pagination with a custom count + * {@link Specification}. + * + * @param + * @since 3.5 + */ + interface SpecificationFluentQuery extends FluentQuery.FetchableFluentQuery { + + @Override + SpecificationFluentQuery sortBy(Sort sort); + + @Override + SpecificationFluentQuery limit(int limit); + + @Override + SpecificationFluentQuery as(Class resultType); + + @Override + default SpecificationFluentQuery project(String... properties) { + return this.project(Arrays.asList(properties)); + } + + @Override + SpecificationFluentQuery project(Collection 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 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}. + * + * @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}. + * Any potentially specified {@link #limit(int)} will be overridden by {@link Pageable#getPageSize()}. + * @param countSpec specification used to count results. + * @return + */ + Page page(Pageable pageable, Specification countSpec); + } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Lock.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Lock.java index 219756b6ef..a8d1fc7775 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Lock.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Lock.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Meta.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Meta.java index 55225de082..2ba8a0a9ea 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Meta.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Meta.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Modifying.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Modifying.java index 48ef4ce55b..612bb4092a 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Modifying.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Modifying.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. 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 new file mode 100644 index 0000000000..d12036c74b --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java @@ -0,0 +1,98 @@ +/* + * 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; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Annotation to declare native queries directly on repository query methods. + *

+ * Specifically {@code @NativeQuery} is a composed annotation that acts as a shortcut for + * {@code @Query(nativeQuery = true)} for most attributes. + *

+ * This annotation defines {@code sqlResultSetMapping} to apply JPA SQL ResultSet mapping for native queries. Make sure + * to use the corresponding return type as defined in {@code @SqlResultSetMapping}. When using named native queries, + * define SQL result set mapping through {@code @NamedNativeQuery(resultSetMapping=…)} as named queries do not accept + * {@code sqlResultSetMapping}. + * + * @author Danny van den Elshout + * @author Mark Paluch + * @since 3.4 + * @see Query + * @see Modifying + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Documented +@Query(nativeQuery = true) +public @interface NativeQuery { + + /** + * Defines the native query to be executed when the annotated method is called. Alias for {@link Query#value()}. + */ + @AliasFor(annotation = Query.class) + String value() default ""; + + /** + * Defines a special count query that shall be used for pagination queries to look up the total number of elements for + * a page. If none is configured we will derive the count query from the original query or {@link #countProjection()} + * query if any. Alias for {@link Query#countQuery()}. + */ + @AliasFor(annotation = Query.class) + String countQuery() default ""; + + /** + * Defines the projection part of the count query that is generated for pagination. If neither {@link #countQuery()} + * nor {@code countProjection()} is configured we will derive the count query from the original query. Alias for + * {@link Query#countProjection()}. + */ + @AliasFor(annotation = Query.class) + String countProjection() default ""; + + /** + * The named query to be used. If not defined, a {@link jakarta.persistence.NamedQuery} with name of + * {@code ${domainClass}.${queryMethodName}} will be used. Alias for {@link Query#name()}. + */ + @AliasFor(annotation = Query.class) + String name() default ""; + + /** + * Returns the name of the {@link jakarta.persistence.NamedQuery} to be used to execute count queries when pagination + * is used. Will default to the named query name configured suffixed by {@code .count}. Alias for + * {@link Query#countName()}. + */ + @AliasFor(annotation = Query.class) + String countName() default ""; + + /** + * Define a {@link QueryRewriter} that should be applied to the query string after the query is fully assembled. Alias + * for {@link Query#queryRewriter()}. + */ + @AliasFor(annotation = Query.class) + Class queryRewriter() default QueryRewriter.IdentityQueryRewriter.class; + + /** + * 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 8fc1433fdd..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -24,13 +24,17 @@ import org.springframework.data.annotation.QueryAnnotation; /** - * Annotation to declare finder queries directly on repository methods. + * Annotation to declare finder queries directly on repository query methods. + *

+ * When using a native query, a {@link NativeQuery @NativeQuery} variant is available. * * @author Oliver Gierke * @author Thomas Darimont * @author Christoph Strobl * @author Greg Turnquist + * @author Danny van den Elshout * @see Modifying + * @see NativeQuery */ @Retention(RetentionPolicy.RUNTIME) @Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE }) @@ -44,7 +48,7 @@ String value() default ""; /** - * Defines a special count query that shall be used for pagination queries to lookup the total number of elements for + * Defines a special count query that shall be used for pagination queries to look up the total number of elements for * a page. If none is configured we will derive the count query from the original query or {@link #countProjection()} * query if any. */ @@ -52,7 +56,7 @@ /** * Defines the projection part of the count query that is generated for pagination. If neither {@link #countQuery()} - * nor {@link #countProjection()} is configured we will derive the count query from the original query. + * nor {@code countProjection()} is configured we will derive the count query from the original query. * * @return * @since 1.6 @@ -66,7 +70,7 @@ /** * The named query to be used. If not defined, a {@link jakarta.persistence.NamedQuery} with name of - * {@code $ domainClass}.${queryMethodName}} will be used. + * {@code ${domainClass}.${queryMethodName}} will be used. */ String name() default ""; @@ -86,4 +90,5 @@ * @since 3.0 */ Class queryRewriter() default QueryRewriter.IdentityQueryRewriter.class; + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/QueryHints.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/QueryHints.java index 86b482db80..996b8a2933 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/QueryHints.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/QueryHints.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/QueryRewriter.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/QueryRewriter.java index d55d9a31a6..619d6231a9 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/QueryRewriter.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/QueryRewriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -26,6 +26,10 @@ * and tools intends to do has been done. You can customize the query to apply final changes. Rewriting can only make * use of already existing contextual data. That is, adding or replacing query text or reuse of bound parameters. Query * rewriting must not add additional bindable parameters as these cannot be materialized. + *

+ * 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 + * {@code SelectionQuery}. * * @author Greg Turnquist * @author Mark Paluch @@ -71,4 +75,5 @@ public String rewrite(String query, Sort sort) { return query; } } + } 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 7668ccd33f..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -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/main/java/org/springframework/data/jpa/repository/aot/AotEntityGraph.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotEntityGraph.java new file mode 100644 index 0000000000..388c041cb4 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotEntityGraph.java @@ -0,0 +1,31 @@ +/* + * 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.jspecify.annotations.Nullable; + +import org.springframework.data.jpa.repository.EntityGraph; + +/** + * 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 Mark Paluch + */ +record AotEntityGraph(@Nullable String name, EntityGraph.EntityGraphType type, List 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..9146bbe395 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotMetamodel.java @@ -0,0 +1,143 @@ +/* + * 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.PersistenceUnitInfo; + +import java.net.URL; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +import org.hibernate.cfg.JdbcSettings; +import org.hibernate.dialect.H2Dialect; +import org.hibernate.engine.jdbc.connections.internal.UserSuppliedConnectionProviderImpl; +import org.hibernate.jpa.HibernatePersistenceProvider; +import org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl; +import org.hibernate.jpa.boot.internal.PersistenceUnitInfoDescriptor; +import org.jspecify.annotations.Nullable; +import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.util.Lazy; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; +import org.springframework.orm.jpa.persistenceunit.SpringPersistenceUnitInfo; + +/** + * AOT metamodel implementation that uses Hibernate to build the metamodel. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 4.0 + */ +class AotMetamodel implements Metamodel { + + private final Lazy entityManagerFactory; + private final Lazy entityManager = Lazy.of(() -> getEntityManagerFactory().createEntityManager()); + + public AotMetamodel(AotRepositoryContext repositoryContext) { + this(repositoryContext.getResolvedTypes().stream().filter(AotMetamodel::isJakartaAnnotated).map(Class::getName) + .toList(), null); + } + + private static boolean isJakartaAnnotated(Class cls) { + + return cls.isAnnotationPresent(jakarta.persistence.Entity.class) + || cls.isAnnotationPresent(jakarta.persistence.Embeddable.class) + || cls.isAnnotationPresent(jakarta.persistence.MappedSuperclass.class) + || cls.isAnnotationPresent(jakarta.persistence.Converter.class); + } + + public AotMetamodel(PersistenceManagedTypes managedTypes) { + this(managedTypes.getManagedClassNames(), managedTypes.getPersistenceUnitRootUrl()); + } + + public AotMetamodel(Collection managedTypes, @Nullable URL persistenceUnitRootUrl) { + + SpringPersistenceUnitInfo persistenceUnitInfo = new SpringPersistenceUnitInfo( + managedTypes.getClass().getClassLoader()); + persistenceUnitInfo.setPersistenceUnitName("AotMetamodel"); + persistenceUnitInfo.setPersistenceUnitRootUrl(persistenceUnitRootUrl); + + this.entityManagerFactory = init(() -> { + + managedTypes.forEach(persistenceUnitInfo::addManagedClassName); + + persistenceUnitInfo.setPersistenceProviderClassName(HibernatePersistenceProvider.class.getName()); + return new PersistenceUnitInfoDescriptor(persistenceUnitInfo.asStandardPersistenceUnitInfo()); + }); + } + + public AotMetamodel(PersistenceUnitInfo unitInfo) { + this.entityManagerFactory = init(() -> new PersistenceUnitInfoDescriptor(unitInfo)); + } + + static Lazy init(Supplier unitInfo) { + + return Lazy.of(() -> new EntityManagerFactoryBuilderImpl(unitInfo.get(), + Map.of(JdbcSettings.DIALECT, H2Dialect.class.getName(), // + JdbcSettings.ALLOW_METADATA_ON_BOOT, "false", // + JdbcSettings.CONNECTION_PROVIDER, new UserSuppliedConnectionProviderImpl())) + .build()); + } + + private Metamodel getMetamodel() { + return getEntityManagerFactory().getMetamodel(); + } + + public EntityType entity(Class cls) { + return getMetamodel().entity(cls); + } + + @Override + public EntityType entity(String s) { + return getMetamodel().entity(s); + } + + public ManagedType managedType(Class cls) { + return getMetamodel().managedType(cls); + } + + public EmbeddableType embeddable(Class cls) { + return getMetamodel().embeddable(cls); + } + + public Set> getManagedTypes() { + return getMetamodel().getManagedTypes(); + } + + public Set> getEntities() { + return getMetamodel().getEntities(); + } + + public Set> getEmbeddables() { + return getMetamodel().getEmbeddables(); + } + + public EntityManager entityManager() { + return entityManager.get(); + } + + public EntityManagerFactory getEntityManagerFactory() { + return entityManagerFactory.get(); + } + +} 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..7b223f2645 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQueries.java @@ -0,0 +1,149 @@ +/* + * 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.List; +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) { + + public AotQueries(AotQuery query) { + this(query, AbsentCountQuery.INSTANCE); + } + + /** + * Derive a count query from the given query. + */ + public static AotQueries withDerivedCountQuery(T query, Function queryMapper, + @Nullable String countProjection, QueryEnhancerSelector selector) { + + DeclaredQuery underlyingQuery = queryMapper.apply(query); + QueryEnhancer queryEnhancer = selector.select(underlyingQuery).create(underlyingQuery); + + if (!queryEnhancer.isSelectQuery()) { + return new AotQueries(query); + } + + String derivedCountQuery = queryEnhancer + .createCountQueryFor(StringUtils.hasText(countProjection) ? countProjection : null); + + return new AotQueries(query, StringAotQuery.of(underlyingQuery.rewrite(derivedCountQuery))); + } + + /** + * 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); + } + + private static class AbsentCountQuery extends AotQuery { + + static final AbsentCountQuery INSTANCE = new AbsentCountQuery(); + + AbsentCountQuery() { + super(List.of()); + } + + @Override + public boolean isNative() { + return false; + } + + @Override + public boolean hasConstructorExpressionOrDefaultProjection() { + return false; + } + } + + /** + * 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 serialize() { + + Map 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 (result() instanceof StringAotQuery.NamedStringAotQuery nsq) { + serialized.put("name", nsq.getQueryName()); + } + + 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()); + } + + if (count() instanceof StringAotQuery.NamedStringAotQuery nsq) { + serialized.put("count-name", nsq.getQueryName()); + } + } + + 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..9abaf1dab6 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQuery.java @@ -0,0 +1,103 @@ +/* + * 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.EntityQuery; +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 parameterBindings; + + AotQuery(List parameterBindings) { + this.parameterBindings = parameterBindings; + } + + static boolean hasConstructorExpressionOrDefaultProjection(EntityQuery query) { + return query.hasConstructorExpression() || query.isDefaultProjection(); + } + + /** + * @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 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; + } + + /** + * @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(); + +} 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..cb16ed8702 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotRepositoryFragmentSupport.java @@ -0,0 +1,235 @@ +/* + * 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.Array; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Optional; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.CollectionFactory; +import org.springframework.core.convert.ConversionService; +import org.springframework.core.convert.support.ConfigurableConversionService; +import org.springframework.core.convert.support.DefaultConversionService; +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.JpaResultConverters; +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 static final ConversionService CONVERSION_SERVICE; + + static { + + ConfigurableConversionService conversionService = new DefaultConversionService(); + + conversionService.addConverter(JpaResultConverters.BlobToByteArrayConverter.INSTANCE); + conversionService.removeConvertible(Collection.class, Object.class); + conversionService.removeConvertible(Object.class, Optional.class); + + CONVERSION_SERVICE = conversionService; + } + + private final RepositoryMetadata repositoryMetadata; + + private final ValueExpressionDelegate valueExpressions; + + private final ProjectionFactory projectionFactory; + + private final Lazy> enhancers; + + private final Lazy> expressions; + + private final Lazy> 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 @Nullable Object mapIgnoreCase(@Nullable Object source, UnaryOperator mapper) { + + if (source == null) { + return null; + } + + if (source.getClass().isArray()) { + int length = Array.getLength(source); + Collection result = new ArrayList<>(length); + + for (int i = 0; i < length; i++) { + result.add(Array.get(source, i)); + } + source = result; + } + + if (source instanceof Collection c) { + + Collection<@Nullable Object> result = new ArrayList<>(c.size()); + + for (Object o : c) { + + if (o instanceof String s) { + result.add(mapper.apply(s)); + } else { + result.add(o != null ? mapper.apply(o.toString()) : null); + } + } + + return result; + } + + return source; + } + + protected @Nullable T convertOne(@Nullable Object result, boolean nativeQuery, Class projection) { + + if (result == null) { + return null; + } + + if (projection.isInstance(result)) { + return projection.cast(result); + } + + if (CONVERSION_SERVICE.canConvert(result.getClass(), projection)) { + return CONVERSION_SERVICE.convert(result, projection); + } + + 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, + 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 entityGraphNames = getEntityGraphNames(entityGraph, information, queryMethod); + List> candidates = Arrays.asList(returnedType.getDomainType(), returnedType.getReturnedType(), + returnedType.getTypeToRead()); + + for (Class candidate : candidates) { + + Map> 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 getEntityGraphNames(MergedAnnotation entityGraph, RepositoryInformation information, + JpaQueryMethod queryMethod) { + + Set 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..08cca9685c --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaCodeBlocks.java @@ -0,0 +1,812 @@ +/* + * 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.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.function.LongSupplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.MethodParameter; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Score; +import org.springframework.data.domain.SliceImpl; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.javapoet.LordOfTheStrings; +import org.springframework.data.javapoet.TypeNames; +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.jpa.repository.support.JpqlQueryTemplates; +import org.springframework.data.repository.aot.generate.AotQueryMethodGenerationContext; +import org.springframework.data.repository.aot.generate.MethodReturn; +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 final String parameterNames; + private final String queryVariableName; + private @Nullable AotQueries queries; + private MergedAnnotation 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"); + + String parameterNames = StringUtils.collectionToDelimitedString(context.getAllParameterNames(), ", "); + + if (StringUtils.hasText(parameterNames)) { + this.parameterNames = ", " + parameterNames; + } else { + this.parameterNames = ""; + } + } + + public QueryBlockBuilder filter(AotQueries query) { + this.queries = query; + return this; + } + + public QueryBlockBuilder nativeQuery(MergedAnnotation nativeQuery) { + + if (nativeQuery.isPresent()) { + this.sqlResultSetMapping = nativeQuery.getString("sqlResultSetMapping"); + } + return this; + } + + public QueryBlockBuilder queryHints(MergedAnnotation 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"); + + MethodReturn methodReturn = context.getMethodReturn(); + boolean isProjecting = methodReturn.isProjecting(); + + 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 pageable = context.getPageableParameterName(); + + if (pageable != null) { + String pageableVariableName = context.localVariable("pageable"); + builder.addStatement("$1T $2L = $3L != null ? $3L : $1T.unpaged()", Pageable.class, pageableVariableName, + pageable); + pageable = pageableVariableName; + } + + String sortParameterName = context.getSortParameterName(); + if (sortParameterName == null && pageable != null) { + sortParameterName = "%s.getSort()".formatted(pageable); + } + + if ((StringUtils.hasText(sortParameterName) || StringUtils.hasText(dynamicReturnType)) + && queries != null && queries.result() instanceof StringAotQuery + && StringUtils.hasText(queryStringVariableName)) { + builder.add(applyRewrite(sortParameterName, dynamicReturnType, isProjecting, queryStringVariableName)); + } + + builder.add(createQuery(false, queryVariableName, queryStringVariableName, queryRewriterName, queries.result(), + this.sqlResultSetMapping, pageable, this.queryHints, this.entityGraph, this.queryReturnType)); + + builder.add(applyLimits(queries.result().isExists(), pageable)); + + 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, pageable, + 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, boolean isProjecting, + String queryString) { + + 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) { + + Object actualReturnType = isProjecting ? context.getMethodReturn().getActualClassName() + : context.getDomainType(); + + 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, @Nullable String pageable) { + + Assert.notNull(queries, "Queries must not be null"); + + Builder builder = CodeBlock.builder(); + + if (exists) { + builder.addStatement("$L.setMaxResults(1)", queryVariableName); + return builder.build(); + } + + if (queries != null && queries.result() instanceof StringAotQuery sq && sq.hasPagingExpression()) { + 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(); + } + + 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(); + } + + if (queries.result().isLimited()) { + + int max = queries.result().getLimit().max(); + + builder.beginControlFlow("if ($L.getMaxResults() != $T.MAX_VALUE)", queryVariableName, Integer.class); + builder.beginControlFlow("if ($1L.getMaxResults() > $2L && $1L.getFirstResult() > 0)", queryVariableName, max); + builder.addStatement("$1L.setFirstResult($1L.getFirstResult() - ($1L.getMaxResults() - $2L))", + queryVariableName, max); + builder.endControlFlow(); + builder.endControlFlow(); + + builder.addStatement("$1L.setMaxResults($2L)", queryVariableName, max); + } + + return builder.build(); + } + + private CodeBlock createQuery(boolean count, String queryVariableName, @Nullable String queryStringNameVariableName, + @Nullable String queryRewriterName, AotQuery query, @Nullable String sqlResultSetMapping, + @Nullable String pageable, + MergedAnnotation queryHints, + @Nullable AotEntityGraph entityGraph, @Nullable Class queryReturnType) { + + Builder builder = CodeBlock.builder(); + + builder.add(doCreateQuery(count, queryVariableName, queryStringNameVariableName, queryRewriterName, query, + sqlResultSetMapping, pageable, + 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"; + + Object parameter = getParameter(binding.getOrigin()); + + if (parameter instanceof String parameterName) { + MethodParameter methodParameter = context.getMethodParameter(parameterName); + if (methodParameter != null) { + parameter = postProcessBindingValue(binding, methodParameter, parameterName); + } + } + + 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, parameter); + } else { + builder.addStatement("$L.setParameter(%s, $L)".formatted(valueFormat), queryVariableName, parameterIdentifier, + parameter); + } + } + + return builder.build(); + } + + private Object postProcessBindingValue(ParameterBinding binding, MethodParameter methodParameter, + String parameterName) { + + Class parameterType = methodParameter.getParameterType(); + if (Score.class.isAssignableFrom(parameterType)) { + return parameterName + ".getValue()"; + } + + if (Vector.class.isAssignableFrom(parameterType)) { + return "%1$s.getType() == Float.TYPE ? %1$s.toFloatArray() : %1$s.toDoubleArray()".formatted(parameterName); + } + + if (binding instanceof ParameterBinding.PartTreeParameterBinding treeBinding) { + + if (treeBinding.isIgnoreCase()) { + + String function = treeBinding.getTemplates() == JpqlQueryTemplates.LOWER ? "toLowerCase" : "toUpperCase"; + + if (isArray(parameterType) || Collection.class.isAssignableFrom(parameterType)) { + return CodeBlock.builder().add("mapIgnoreCase($L, $T::$L)", parameterName, String.class, function).build(); + } + + if (String.class.isAssignableFrom(parameterType)) { + return "%1$s != null ? %1$s.%2$s() : %1$s".formatted(parameterName, function); + } + + return "%1$s != null ? %1$s.toString().%2$s() : %1$s".formatted(parameterName, function); + } + } + + if (isArray(parameterType)) { + return CodeBlock.builder().add("$T.asList($L)", Arrays.class, parameterName).build(); + } + + return parameterName; + } + + private static boolean isArray(Class parameterType) { + return parameterType.isArray() && !parameterType.getComponentType().equals(byte.class) + && !parameterType.getComponentType().equals(Byte.class); + } + + private CodeBlock doCreateQuery(boolean count, String queryVariableName, + @Nullable String queryStringName, @Nullable String queryRewriterName, AotQuery query, + @Nullable String sqlResultSetMapping, + @Nullable String pageable, + @Nullable Class queryReturnType) { + + MethodReturn methodReturn = context.getMethodReturn(); + Builder builder = CodeBlock.builder(); + String queryStringNameToUse = queryStringName; + + if (query instanceof StringAotQuery sq) { + + if (StringUtils.hasText(queryRewriterName)) { + + queryStringNameToUse = queryStringName + "Rewritten"; + + if (StringUtils.hasText(pageable)) { + builder.addStatement("$T $L = $L.rewrite($L, $L)", String.class, queryStringNameToUse, queryRewriterName, + queryStringName, pageable); + } 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 && methodReturn.isInterfaceProjection()) { + 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 && methodReturn.isInterfaceProjection()) { + 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 && !nq.hasConstructorExpressionOrDefaultProjection() && methodReturn.isInterfaceProjection()) { + queryReturnType = Tuple.class; + } + + 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(); + + String expressionString = expr.expression().getExpressionString(); + // re-wrap expression + if (!expressionString.startsWith("$")) { + expressionString = "#{" + expressionString + "}"; + } + + builder.add("evaluateExpression($L, $S$L)", context.getExpressionMarker().enclosingMethod(), expressionString, + parameterNames); + + return builder.build(); + } + + throw new UnsupportedOperationException("Not supported yet for: " + origin); + } + + 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.getDomainType(), + context.localVariable("entityGraph"), + context.fieldNameOf(EntityManager.class), context.getDomainType()); + + 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) { + + Builder hintsBuilder = CodeBlock.builder(); + MergedAnnotation[] values = queryHints.getAnnotationArray("value", QueryHint.class); + + for (MergedAnnotation 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 final String queryVariableName; + private @Nullable AotQuery aotQuery; + private @Nullable String pageable; + private MergedAnnotation modifying = MergedAnnotation.missing(); + + private QueryExecutionBlockBuilder(AotQueryMethodGenerationContext context, JpaQueryMethod queryMethod) { + + this.context = context; + this.queryMethod = queryMethod; + this.queryVariableName = context.localVariable("query"); + this.pageable = context.getPageableParameterName() != null ? context.localVariable("pageable") : null; + } + + public QueryExecutionBlockBuilder query(AotQuery aotQuery) { + + this.aotQuery = aotQuery; + return this; + } + + public QueryExecutionBlockBuilder query(String pageable) { + + this.pageable = pageable; + return this; + } + + public QueryExecutionBlockBuilder modifying(MergedAnnotation modifying) { + + this.modifying = modifying; + return this; + } + + public CodeBlock build() { + + Builder builder = CodeBlock.builder(); + MethodReturn methodReturn = context.getMethodReturn(); + boolean isProjecting = methodReturn.isProjecting() + || !ObjectUtils.nullSafeEquals(context.getDomainType(), methodReturn.getActualReturnClass()) + || StringUtils.hasText(context.getDynamicProjectionParameterName()); + TypeName typeToRead = isProjecting ? methodReturn.getActualTypeName() + : TypeName.get(context.getDomainType()); + builder.add("\n"); + + if (modifying.isPresent()) { + + if (modifying.getBoolean("flushAutomatically")) { + builder.addStatement("this.$L.flush()", context.fieldNameOf(EntityManager.class)); + } + + Class returnType = methodReturn.toClass(); + + 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 $L = $L.getResultList()", List.class, + context.localVariable("resultList"), queryVariableName); + + boolean returnCount = ClassUtils.isAssignable(Number.class, methodReturn.toClass()); + boolean simpleBatch = returnCount || methodReturn.isVoid(); + boolean collectionQuery = queryMethod.isCollectionQuery(); + + if (!simpleBatch && !collectionQuery) { + + builder.beginControlFlow("if ($L.size() > 1)", context.localVariable("resultList")); + builder.addStatement("throw new $1T($2S + $3L.size(), 1, $3L.size())", + IncorrectResultSizeDataAccessException.class, + "Delete query returned more than one element: expected 1, actual ", context.localVariable("resultList")); + builder.endControlFlow(); + } + + builder.addStatement("$L.forEach($L::remove)", context.localVariable("resultList"), + context.fieldNameOf(EntityManager.class)); + + if (collectionQuery) { + builder.addStatement("return ($T) $L", List.class, context.localVariable("resultList")); + + } else if (returnCount) { + builder.addStatement("return $T.valueOf($L.size())", + ClassUtils.resolvePrimitiveIfNecessary(methodReturn.getActualReturnClass()), + context.localVariable("resultList")); + } else { + + builder.addStatement(LordOfTheStrings.returning(methodReturn.toClass()) + .optional("($1T) ($2L.isEmpty() ? null : $2L.iterator().next())", typeToRead, + context.localVariable("resultList")) // + .build()); + } + } else if (aotQuery != null && aotQuery.isExists()) { + builder.addStatement("return !$L.getResultList().isEmpty()", queryVariableName); + } else if (aotQuery != null) { + + if (isProjecting) { + + TypeName returnType = TypeNames.typeNameOrWrapper(methodReturn.getActualType()); + CodeBlock convertTo; + if (StringUtils.hasText(context.getDynamicProjectionParameterName())) { + convertTo = CodeBlock.of("$L", context.getDynamicProjectionParameterName()); + } else { + + if (methodReturn.isArray() && methodReturn.getActualType().toClass().equals(byte.class)) { + returnType = TypeName.get(byte[].class); + convertTo = CodeBlock.of("$T.class", returnType); + } else { + convertTo = CodeBlock.of("$T.class", TypeNames.classNameOrWrapper(methodReturn.getActualType())); + } + } + + if (queryMethod.isCollectionQuery()) { + builder.addStatement("return ($T) convertMany($L.getResultList(), $L, $L)", methodReturn.getTypeName(), + queryVariableName, aotQuery.isNative(), convertTo); + } else if (queryMethod.isStreamQuery()) { + builder.addStatement("return ($T) convertMany($L.getResultStream(), $L, $L)", methodReturn.getTypeName(), + queryVariableName, aotQuery.isNative(), convertTo); + } else if (queryMethod.isPageQuery()) { + builder.addStatement( + "return $T.getPage(($T<$T>) convertMany($L.getResultList(), $L, $L), $L, $L)", + PageableExecutionUtils.class, List.class, TypeNames.typeNameOrWrapper(methodReturn.getActualType()), + queryVariableName, aotQuery.isNative(), convertTo, pageable, context.localVariable("countAll")); + } else if (queryMethod.isSliceQuery()) { + builder.addStatement("$T<$T> $L = ($T<$T>) convertMany($L.getResultList(), $L, $L)", List.class, + TypeNames.typeNameOrWrapper(methodReturn.getActualType()), context.localVariable("resultList"), + List.class, typeToRead, queryVariableName, + aotQuery.isNative(), + convertTo); + builder.addStatement("boolean $L = $L.isPaged() && $L.size() > $L.getPageSize()", + context.localVariable("hasNext"), pageable, context.localVariable("resultList"), pageable); + builder.addStatement( + "return new $T<>($L ? $L.subList(0, $L.getPageSize()) : $L, $L, $L)", SliceImpl.class, + context.localVariable("hasNext"), context.localVariable("resultList"), + pageable, context.localVariable("resultList"), pageable, context.localVariable("hasNext")); + } else { + + builder.addStatement(LordOfTheStrings.returning(methodReturn.toClass()) + .optional("($T) convertOne($L.getSingleResultOrNull(), $L, $L)", returnType, queryVariableName, + aotQuery.isNative(), convertTo) // + .build()); + } + + } else { + + if (queryMethod.isCollectionQuery()) { + builder.addStatement("return ($T) $L.getResultList()", methodReturn.getTypeName(), queryVariableName); + } else if (queryMethod.isStreamQuery()) { + builder.addStatement("return ($T) $L.getResultStream()", methodReturn.getTypeName(), queryVariableName); + } else if (queryMethod.isPageQuery()) { + builder.addStatement("return $T.getPage(($T<$T>) $L.getResultList(), $L, $L)", + PageableExecutionUtils.class, List.class, typeToRead, queryVariableName, + pageable, context.localVariable("countAll")); + } else if (queryMethod.isSliceQuery()) { + builder.addStatement("$T<$T> $L = $L.getResultList()", List.class, typeToRead, + context.localVariable("resultList"), queryVariableName); + builder.addStatement("boolean $L = $L.isPaged() && $L.size() > $L.getPageSize()", + context.localVariable("hasNext"), pageable, context.localVariable("resultList"), pageable); + builder.addStatement( + "return new $T<>($L ? $L.subList(0, $L.getPageSize()) : $L, $L, $L)", SliceImpl.class, + context.localVariable("hasNext"), context.localVariable("resultList"), + pageable, context.localVariable("resultList"), pageable, context.localVariable("hasNext")); + } else { + + builder.addStatement(LordOfTheStrings.returning(methodReturn.toClass()) + .optional("($T) convertOne($L.getSingleResultOrNull(), $L, $T.class)", + TypeNames.typeNameOrWrapper(methodReturn.getActualType()), queryVariableName, aotQuery.isNative(), + TypeNames.classNameOrWrapper(methodReturn.getActualType())) // + .build()); + } + } + } + + 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..70de4f637f --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributor.java @@ -0,0 +1,304 @@ +/* + * 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.PersistenceUnitUtil; +import jakarta.persistence.metamodel.Metamodel; +import jakarta.persistence.spi.PersistenceUnitInfo; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.config.RuntimeBeanReference; +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.provider.QueryExtractor; +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.JpaEntityMetadata; +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.jpa.repository.support.JpaEntityInformationSupport; +import org.springframework.data.projection.ProjectionFactory; +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.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactoryBeanSupport; +import org.springframework.data.repository.query.ParametersSource; +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.orm.jpa.persistenceunit.PersistenceManagedTypes; +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. + *

+ * 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 Metamodel metamodel; + private final PersistenceUnitUtil persistenceUnitUtil; + private final PersistenceProvider persistenceProvider; + private final QueriesFactory queriesFactory; + private final EntityGraphLookup entityGraphLookup; + private final AotRepositoryContext context; + + public JpaRepositoryContributor(AotRepositoryContext repositoryContext) { + this(repositoryContext, new AotMetamodel(repositoryContext)); + } + + public JpaRepositoryContributor(AotRepositoryContext repositoryContext, PersistenceUnitInfo unitInfo) { + this(repositoryContext, new AotMetamodel(unitInfo)); + } + + public JpaRepositoryContributor(AotRepositoryContext repositoryContext, PersistenceManagedTypes managedTypes) { + this(repositoryContext, new AotMetamodel(managedTypes)); + } + + public JpaRepositoryContributor(AotRepositoryContext repositoryContext, EntityManagerFactory entityManagerFactory) { + this(repositoryContext, entityManagerFactory, entityManagerFactory.getMetamodel()); + } + + private JpaRepositoryContributor(AotRepositoryContext repositoryContext, AotMetamodel metamodel) { + this(repositoryContext, metamodel.getEntityManagerFactory(), metamodel); + } + + private JpaRepositoryContributor(AotRepositoryContext repositoryContext, EntityManagerFactory entityManagerFactory, + Metamodel metamodel) { + + super(repositoryContext); + + this.metamodel = metamodel; + this.persistenceUnitUtil = entityManagerFactory.getPersistenceUnitUtil(); + this.persistenceProvider = PersistenceProvider.fromEntityManagerFactory(entityManagerFactory); + this.queriesFactory = new QueriesFactory(repositoryContext.getConfigurationSource(), entityManagerFactory, + repositoryContext.getRequiredClassLoader()); + this.entityGraphLookup = new EntityGraphLookup(entityManagerFactory); + this.context = repositoryContext; + } + + @Override + protected void customizeClass(AotRepositoryClassBuilder classBuilder) { + classBuilder.customize(builder -> builder.superclass(TypeName.get(AotRepositoryFragmentSupport.class))); + } + + @Override + protected void customizeConstructor(AotRepositoryConstructorBuilder constructorBuilder) { + + String entityManagerFactoryRef = getEntityManagerFactoryRef(); + + constructorBuilder.addParameter("entityManager", EntityManager.class, customizer -> { + + customizer.bindToField().origin( + StringUtils.hasText(entityManagerFactoryRef) + ? new RuntimeBeanReference(entityManagerFactoryRef, EntityManager.class) + : new RuntimeBeanReference(EntityManager.class)); + }); + + constructorBuilder.addParameter("context", RepositoryFactoryBeanSupport.FragmentCreationContext.class); + + Optional> queryEnhancerSelector = getQueryEnhancerSelectorClass(); + + constructorBuilder.customize(builder -> { + + if (queryEnhancerSelector.isPresent()) { + builder.addStatement("super(new T$(), context)", queryEnhancerSelector.get()); + } else { + builder.addStatement("super($T.DEFAULT_SELECTOR, context)", QueryEnhancerSelector.class); + } + }); + } + + private @Nullable String getEntityManagerFactoryRef() { + return context.getConfigurationSource().getAttribute("entityManagerFactoryRef") + .filter(it -> !"entityManagerFactory".equals(it)).orElse(null); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + private Optional> getQueryEnhancerSelectorClass() { + return (Optional) context.getConfigurationSource().getAttribute("queryEnhancerSelector", Class.class) + .filter(it -> !it.equals(QueryEnhancerSelector.DefaultQueryEnhancerSelector.class)); + } + + @Override + protected @Nullable MethodContributor contributeQueryMethod(Method method) { + + JpaEntityMetadata entityInformation = JpaEntityInformationSupport + .getEntityInformation(getRepositoryInformation().getDomainType(), metamodel, persistenceUnitUtil); + AotJpaQueryMethod queryMethod = new AotJpaQueryMethod(method, getRepositoryInformation(), entityInformation, + getProjectionFactory(), persistenceProvider, JpaParameters::new); + + Optional> queryEnhancerSelectorClass = getQueryEnhancerSelectorClass(); + QueryEnhancerSelector selector = queryEnhancerSelectorClass.map(BeanUtils::instantiateClass) + .orElse(QueryEnhancerSelector.DEFAULT_SELECTOR); + + // no stored procedures for now. + if (queryMethod.isProcedureQuery()) { + + Procedure procedure = AnnotatedElementUtils.findMergedAnnotation(method, Procedure.class); + + MethodContributor.QueryMethodMetadataContributorBuilder 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 = MergedAnnotations.from(method).get(Query.class); + + AotQueries aotQueries = queriesFactory.createQueries(getRepositoryInformation(), returnedType, selector, query, + queryMethod); + + // no KeysetScrolling for now. + if (parameters.hasScrollPositionParameter() || queryMethod.isScrollQuery()) { + 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 = context.getAnnotation(NativeQuery.class); + MergedAnnotation queryHints = context.getAnnotation(QueryHints.class); + MergedAnnotation entityGraph = context.getAnnotation(EntityGraph.class); + MergedAnnotation 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(); + }); + } + + public Metamodel getMetamodel() { + return metamodel; + } + + record StoredProcedureMetadata(String procedure) implements QueryMetadata { + + @Override + public Map serialize() { + return Map.of("procedure", procedure()); + } + } + + record NamedStoredProcedureMetadata(String procedureName) implements QueryMetadata { + + @Override + public Map serialize() { + return Map.of("procedure-name", procedureName()); + } + } + + /** + * AOT extension to {@link JpaQueryMethod} providing a metamodel backed {@link JpaEntityMetadata} object. + */ + static class AotJpaQueryMethod extends JpaQueryMethod { + + private final JpaEntityMetadata entityMetadata; + + public AotJpaQueryMethod(Method method, RepositoryMetadata metadata, JpaEntityMetadata entityMetadata, + ProjectionFactory factory, QueryExtractor extractor, + Function parametersFunction) { + + super(method, metadata, factory, extractor, parametersFunction); + + this.entityMetadata = entityMetadata; + } + + @Override + public JpaEntityMetadata getEntityInformation() { + return this.entityMetadata; + } + + } + +} 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 955401af7d..3acd19fd00 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.List; +import org.jspecify.annotations.Nullable; import org.springframework.aot.hint.ExecutableMode; import org.springframework.aot.hint.MemberCategory; import org.springframework.aot.hint.RuntimeHints; @@ -32,11 +33,11 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.query.QueryEnhancerSelector.DefaultQueryEnhancerSelector; import org.springframework.data.jpa.repository.support.QuerydslJpaPredicateExecutor; 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; /** @@ -68,6 +69,11 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) MemberCategory.INVOKE_DECLARED_METHODS)); } + // via JpaRepositoryFactoryBean creating the bean if not defined + hints.reflection().registerType(TypeReference.of(DefaultQueryEnhancerSelector.class), + hint -> hint.withMembers(MemberCategory.INVOKE_PUBLIC_CONSTRUCTORS, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, + MemberCategory.INVOKE_PUBLIC_METHODS)); + hints.reflection().registerType(TypeReference.of(SimpleJpaRepository.class), hint -> hint.withMembers(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS, MemberCategory.INVOKE_PUBLIC_METHODS)); @@ -77,7 +83,7 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) // make sure annotations on the fields are visible and allow reflection on protected methods hints.reflection().registerTypes( List.of(TypeReference.of(AbstractPersistable.class), TypeReference.of(AbstractAuditable.class)), - hint -> hint.withMembers(MemberCategory.DECLARED_FIELDS, MemberCategory.INVOKE_DECLARED_METHODS)); + hint -> hint.withMembers(MemberCategory.ACCESS_DECLARED_FIELDS, MemberCategory.INVOKE_DECLARED_METHODS)); if (QuerydslUtils.QUERY_DSL_PRESENT) { @@ -87,12 +93,28 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) } // streaming results requires reflective access to jakarta.persistence.Query#getResultAsStream - hints.reflection().registerType(jakarta.persistence.Query.class, MemberCategory.INTROSPECT_PUBLIC_METHODS); - hints.reflection().registerType(jakarta.persistence.Query.class, hint -> { - hint.withMethod("getResultStream", Collections.emptyList(), ExecutableMode.INVOKE); - }); + hints.reflection().registerType(jakarta.persistence.Query.class, + hint -> hint.withMethod("getResultStream", Collections.emptyList(), ExecutableMode.INVOKE)); hints.reflection().registerType(NamedEntityGraph.class, hint -> hint.onReachableType(EntityGraph.class).withMembers(MemberCategory.INVOKE_PUBLIC_METHODS)); + + if (ClassUtils.isPresent("org.hibernate.Hibernate", classLoader)) { + + /* + Fetching a single results causes: + java.lang.IllegalArgumentException: Class org.hibernate.query.sqm.tree.select.SqmQueryPart[] is instantiated reflectively but was never registered.Register the class by adding "unsafeAllocated" for the class in reflect-config.json. + at org.graalvm.nativeimage.builder/com.oracle.svm.core.graal.snippets.SubstrateAllocationSnippets.arrayHubErrorStub(SubstrateAllocationSnippets.java:345) + at org.hibernate.internal.util.collections.StandardStack.push(StandardStack.java:48) + at org.hibernate.query.sqm.sql.BaseSqmToSqlAstConverter.visitQuerySpec(BaseSqmToSqlAstConverter.java:2073) + + both formats: + - org.hibernate.query.sqm.tree.select.SqmQueryPart[] + - [Lorg.hibernate.query.sqm.tree.select.SqmQueryPart; + seem to be supported via reflect-config. However TypeReference does not support [L... + */ + hints.reflection().registerType(TypeReference.of("org.hibernate.query.sqm.tree.select.SqmQueryPart[]"), + MemberCategory.UNSAFE_ALLOCATED); + } } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaTypeFilters.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaTypeFilters.java new file mode 100644 index 0000000000..91b8b84a25 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/JpaTypeFilters.java @@ -0,0 +1,58 @@ +/* + * 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.Field; +import java.lang.reflect.Member; +import java.lang.reflect.Method; +import java.util.function.Predicate; + +import org.springframework.data.util.Predicates; +import org.springframework.data.util.TypeCollector; +import org.springframework.data.util.TypeUtils; + +/** + * {@link TypeCollector} predicates to exclude JPA provider types. + * + * @author Mark Paluch + * @since 4.0 + */ +class JpaTypeFilters implements TypeCollector.TypeCollectorFilters { + + /** + * Match for bytecode-enhanced members. + */ + private static final Predicate IS_HIBERNATE_MEMBER = member -> member.getName().startsWith("$$_hibernate"); + + private static final Predicate> CLASS_FILTER = it -> TypeUtils.type(it).isPartOf("org.hibernate", + "org.eclipse.persistence", "jakarta.persistence"); + + @Override + public Predicate> classPredicate() { + return CLASS_FILTER.negate(); + } + + @Override + public Predicate fieldPredicate() { + return Predicates. declaringClass(CLASS_FILTER).or(IS_HIBERNATE_MEMBER).negate(); + } + + @Override + public Predicate methodPredicate() { + return Predicates. declaringClass(CLASS_FILTER).or(IS_HIBERNATE_MEMBER).negate(); + } + +} 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..deb6c21f02 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/NamedAotQuery.java @@ -0,0 +1,69 @@ +/* + * 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 org.springframework.data.jpa.repository.query.DeclaredQuery; +import org.springframework.data.jpa.repository.query.EntityQuery; + +/** + * 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 final boolean constructorExpressionOrDefaultProjection; + + public NamedAotQuery(String name, EntityQuery entityQuery) { + super(entityQuery.getParameterBindings()); + this.name = name; + this.query = entityQuery.getQuery(); + this.constructorExpressionOrDefaultProjection = AotQuery.hasConstructorExpressionOrDefaultProjection(entityQuery); + } + + /** + * Creates a new {@code NamedAotQuery}. + */ + public static NamedAotQuery named(String namedQuery, EntityQuery query) { + return new NamedAotQuery(namedQuery, query); + } + + public String getName() { + return name; + } + + public DeclaredQuery getQuery() { + return query; + } + + public String getQueryString() { + return getQuery().getQueryString(); + } + + @Override + public boolean isNative() { + return query.isNative(); + } + + @Override + public boolean hasConstructorExpressionOrDefaultProjection() { + return constructorExpressionOrDefaultProjection; + } + +} 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..7dd293b313 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java @@ -0,0 +1,345 @@ +/* + * 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.io.IOException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Properties; +import java.util.function.Function; +import java.util.function.UnaryOperator; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +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.config.JpaRepositoryConfigExtension; +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.config.PropertiesBasedNamedQueriesFactoryBean; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; +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 + * @author Christoph Strobl + * @since 4.0 + */ +class QueriesFactory { + + private final EntityManagerFactory entityManagerFactory; + private final NamedQueries namedQueries; + private final Metamodel metamodel; + private final EscapeCharacter escapeCharacter; + private final JpqlQueryTemplates templates = JpqlQueryTemplates.UPPER; + + public QueriesFactory(RepositoryConfigurationSource configurationSource, EntityManagerFactory entityManagerFactory, + ClassLoader classLoader) { + this(configurationSource, entityManagerFactory, entityManagerFactory.getMetamodel(), classLoader); + } + + public QueriesFactory(RepositoryConfigurationSource configurationSource, EntityManagerFactory entityManagerFactory, + Metamodel metamodel, ClassLoader classLoader) { + + this.metamodel = metamodel; + this.namedQueries = getNamedQueries(configurationSource, classLoader); + this.entityManagerFactory = entityManagerFactory; + + Optional escapeCharacter = configurationSource.getAttribute("escapeCharacter", Character.class); + this.escapeCharacter = escapeCharacter.map(EscapeCharacter::of).orElse(EscapeCharacter.DEFAULT); + } + + private NamedQueries getNamedQueries(@Nullable RepositoryConfigurationSource configSource, ClassLoader classLoader) { + + String location = configSource != null ? configSource.getNamedQueryLocation().orElse(null) : null; + + if (location == null) { + location = new JpaRepositoryConfigExtension().getDefaultNamedQueryLocation(); + } + + if (StringUtils.hasText(location)) { + + try { + + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(classLoader); + + PropertiesBasedNamedQueriesFactoryBean factoryBean = new PropertiesBasedNamedQueriesFactoryBean(); + factoryBean.setLocations(resolver.getResources(location)); + factoryBean.afterPropertiesSet(); + return Objects.requireNonNull(factoryBean.getObject()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + return new PropertiesBasedNamedQueries(new Properties()); + } + + /** + * Creates the {@link AotQueries} used within a specific {@link JpaQueryMethod}. + * + * @param repositoryInformation + * @param returnedType + * @param selector + * @param query + * @param queryMethod + * @return + */ + public AotQueries createQueries(RepositoryInformation repositoryInformation, ReturnedType returnedType, + QueryEnhancerSelector selector, MergedAnnotation query, JpaQueryMethod queryMethod) { + + if (query.isPresent() && StringUtils.hasText(query.getString("value"))) { + return buildStringQuery(returnedType, selector, query, queryMethod); + } + + String queryName = queryMethod.getNamedQueryName(); + if (hasNamedQuery(returnedType, queryName)) { + return buildNamedQuery(returnedType, selector, queryName, query, queryMethod); + } + + return buildPartTreeQuery(repositoryInformation, returnedType, selector, query, queryMethod); + } + + private boolean hasNamedQuery(ReturnedType returnedType, String queryName) { + return namedQueries.hasQuery(queryName) || getNamedQuery(returnedType, queryName) != null; + } + + private AotQueries buildStringQuery(ReturnedType returnedType, QueryEnhancerSelector selector, + MergedAnnotation query, JpaQueryMethod queryMethod) { + + UnaryOperator operator = s -> s.replaceAll("#\\{#entityName}", queryMethod.getEntityInformation().getEntityName()); + boolean isNative = query.getBoolean("nativeQuery"); + Function queryFunction = isNative ? DeclaredQuery::nativeQuery : DeclaredQuery::jpqlQuery; + queryFunction = operator.andThen(queryFunction); + + String queryString = query.getString("value"); + + EntityQuery entityQuery = EntityQuery.create(queryFunction.apply(queryString), selector); + StringAotQuery aotStringQuery = StringAotQuery.of(entityQuery); + String countQuery = query.getString("countQuery"); + + 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, StringAotQuery.of(queryFunction.apply(countQuery))); + } + + if (hasNamedQuery(returnedType, queryMethod.getNamedCountQueryName())) { + return AotQueries.from(aotStringQuery, + createNamedAotQuery(returnedType, selector, queryMethod.getNamedCountQueryName(), queryMethod, isNative)); + } + + if (queryMethod.isModifyingQuery()) { + + } + + String countProjection = query.getString("countProjection"); + return AotQueries.withDerivedCountQuery(aotStringQuery, StringAotQuery::getQuery, countProjection, selector); + } + + private AotQueries buildNamedQuery(ReturnedType returnedType, QueryEnhancerSelector selector, String queryName, + MergedAnnotation query, JpaQueryMethod queryMethod) { + + boolean nativeQuery = query.isPresent() && query.getBoolean("nativeQuery"); + AotQuery aotQuery = createNamedAotQuery(returnedType, selector, queryName, queryMethod, nativeQuery); + String countQuery = query.isPresent() ? query.getString("countQuery") : null; + + if (StringUtils.hasText(countQuery)) { + return AotQueries.from(aotQuery, + StringAotQuery + .of(aotQuery.isNative() ? DeclaredQuery.nativeQuery(countQuery) : DeclaredQuery.jpqlQuery(countQuery))); + } + + if (hasNamedQuery(returnedType, queryMethod.getNamedCountQueryName())) { + return AotQueries.from(aotQuery, + createNamedAotQuery(returnedType, selector, queryMethod.getNamedCountQueryName(), queryMethod, nativeQuery)); + } + + String countProjection = query.isPresent() ? query.getString("countProjection") : null; + return AotQueries.withDerivedCountQuery(aotQuery, it -> { + + if (it instanceof StringAotQuery sq) { + return sq.getQuery(); + } + + return ((NamedAotQuery) aotQuery).getQuery(); + }, countProjection, selector); + } + + private AotQuery createNamedAotQuery(ReturnedType returnedType, QueryEnhancerSelector selector, String queryName, + JpaQueryMethod queryMethod, boolean isNative) { + + if (namedQueries.hasQuery(queryName)) { + + String queryString = namedQueries.getQuery(queryName); + + DeclaredQuery query = isNative ? DeclaredQuery.nativeQuery(queryString) : DeclaredQuery.jpqlQuery(queryString); + return StringAotQuery.named(queryName, EntityQuery.create(query, selector)); + } + + TypedQueryReference namedQuery = getNamedQuery(returnedType, queryName); + + Assert.state(namedQuery != null, "Native named query must not be null"); + + return createNamedAotQuery(namedQuery, selector, isNative, queryMethod); + } + + private AotQuery createNamedAotQuery(TypedQueryReference namedQuery, QueryEnhancerSelector selector, + boolean isNative, JpaQueryMethod queryMethod) { + + 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())); + + DeclaredQuery query = isNative ? DeclaredQuery.nativeQuery(queryString) : DeclaredQuery.jpqlQuery(queryString); + + return NamedAotQuery.named(namedQuery.getName(), EntityQuery.create(query, selector)); + } + + private @Nullable TypedQueryReference getNamedQuery(ReturnedType returnedType, String queryName) { + + List> 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> namedQueries = entityManagerFactory.getNamedQueries(candidate); + + if (namedQueries.containsKey(queryName)) { + return namedQueries.get(queryName); + } + } + + return null; + } + + private AotQueries buildPartTreeQuery(RepositoryInformation repositoryInformation, ReturnedType returnedType, + QueryEnhancerSelector selector, + MergedAnnotation query, JpaQueryMethod queryMethod) { + + PartTree partTree = new PartTree(queryMethod.getName(), repositoryInformation.getDomainType()); + AotQuery aotQuery = createQuery(partTree, returnedType, queryMethod.getParameters(), templates, + queryMethod.getEntityInformation()); + + if (query.isPresent() && StringUtils.hasText(query.getString("countQuery"))) { + return AotQueries.from(aotQuery, StringAotQuery.of(DeclaredQuery.jpqlQuery(query.getString("countQuery")))); + } + + if (hasNamedQuery(returnedType, queryMethod.getNamedCountQueryName())) { + return AotQueries.from(aotQuery, + createNamedAotQuery(returnedType, selector, queryMethod.getNamedCountQueryName(), queryMethod, false)); + } + + AotQuery partTreeCountQuery = createCountQuery(partTree, returnedType, queryMethod.getParameters(), templates, + queryMethod.getEntityInformation()); + return AotQueries.from(aotQuery, partTreeCountQuery); + } + + private AotQuery createQuery(PartTree partTree, ReturnedType returnedType, JpaParameters parameters, + JpqlQueryTemplates templates, JpaEntityMetadata entityMetadata) { + + ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, escapeCharacter, templates); + JpaQueryCreator queryCreator = new JpaQueryCreator(partTree, false, returnedType, metadataProvider, templates, + entityMetadata, 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, JpaEntityMetadata entityMetadata) { + + ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(parameters, escapeCharacter, templates); + JpaQueryCreator queryCreator = new JpaCountQueryCreator(partTree, returnedType, metadataProvider, templates, + entityMetadata, 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 (returnedType.isProjecting()) { + + if (returnedType.getReturnedType().isInterface()) { + + if (query.hasConstructorExpressionOrDefaultProjection()) { + return result; + } + + 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..46555c5504 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/StringAotQuery.java @@ -0,0 +1,238 @@ +/* + * 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.EntityQuery; +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 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 {@link EntityQuery}. Parses the query into {@link PreprocessedQuery}. + */ + static StringAotQuery of(EntityQuery query) { + return new DeclaredAotQuery(query); + } + + /** + * Creates a new named (via {@link org.springframework.data.repository.core.NamedQueries}) {@code StringAotQuery} from + * a {@link EntityQuery}. Parses the query into {@link PreprocessedQuery}. + */ + static StringAotQuery named(String queryName, EntityQuery query) { + return new NamedStringAotQuery(queryName, query); + } + + /** + * Creates a JPQL {@code StringAotQuery} using the given bindings and limit. + */ + public static StringAotQuery jpqlQuery(String queryString, List bindings, Limit resultLimit, + boolean delete, boolean exists) { + return new DerivedAotQuery(queryString, bindings, resultLimit, delete, exists); + } + + /** + * @return the underlying declared query. + */ + public abstract DeclaredQuery getQuery(); + + public String getQueryString() { + return getQuery().getQueryString(); + } + + /** + * @return {@literal true} if query uses an own paging mechanism through {@code {#pageable}}. + */ + public abstract boolean hasPagingExpression(); + + public abstract StringAotQuery rewrite(QueryProvider rewritten); + + @Override + public String toString() { + return getQueryString(); + } + + /** + * @author Christoph Strobl + * @author Mark Paluch + */ + private static class DeclaredAotQuery extends StringAotQuery { + + private final PreprocessedQuery query; + private final boolean constructorExpressionOrDefaultProjection; + private final boolean hasPagingExpression; + + DeclaredAotQuery(EntityQuery query) { + super(query.getParameterBindings()); + this.query = query.getQuery(); + this.hasPagingExpression = query.usesPaging(); + this.constructorExpressionOrDefaultProjection = hasConstructorExpressionOrDefaultProjection(query); + } + + DeclaredAotQuery(PreprocessedQuery query, boolean constructorExpressionOrDefaultProjection) { + super(query.getBindings()); + this.query = query; + this.hasPagingExpression = query.containsPageableInSpel(); + 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 boolean hasPagingExpression() { + return hasPagingExpression; + } + + @Override + public StringAotQuery rewrite(QueryProvider rewritten) { + return new DeclaredAotQuery(query.rewrite(rewritten.getQueryString()), constructorExpressionOrDefaultProjection); + } + + } + + static class NamedStringAotQuery extends DeclaredAotQuery { + + private final String queryName; + + NamedStringAotQuery(String queryName, EntityQuery entityQuery) { + super(entityQuery); + this.queryName = queryName; + } + + NamedStringAotQuery(String queryName, PreprocessedQuery query, boolean constructorExpressionOrDefaultProjection) { + super(query, constructorExpressionOrDefaultProjection); + this.queryName = queryName; + } + + public String getQueryName() { + return queryName; + } + + } + + /** + * 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 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 boolean hasPagingExpression() { + return false; + } + + @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/BeanManagerQueryRewriterProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/BeanManagerQueryRewriterProvider.java index e1ef5f475c..4aa304ec04 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/BeanManagerQueryRewriterProvider.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/BeanManagerQueryRewriterProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryBean.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryBean.java index e0bcb27c97..8cb39d7d49 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryBean.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryBean.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryExtension.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryExtension.java index 8b8088c0c9..a10d005a1b 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryExtension.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryExtension.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. 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 b7a90e128e..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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/BeanDefinitionNames.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/BeanDefinitionNames.java index 2304e4b239..55cd78b091 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/BeanDefinitionNames.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/BeanDefinitionNames.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaAuditing.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaAuditing.java index 48304dbac1..6a6577fbbc 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaAuditing.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaAuditing.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. 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 e4b43d72dc..ce89b4ace6 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -24,9 +24,11 @@ import java.lang.annotation.Target; import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.support.BeanNameGenerator; 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; @@ -55,8 +57,20 @@ String[] value() default {}; /** - * Base packages to scan for annotated components. {@link #value()} is an alias for (and mutually exclusive with) this - * attribute. Use {@link #basePackageClasses()} for a type-safe alternative to String-based package names. + * Base packages to scan for annotated components. + *

+ * {@link #value} is an alias for (and mutually exclusive with) this attribute. + *

+ * Supports {@code ${…}} placeholders which are resolved against the {@link org.springframework.core.env.Environment + * Environment} as well as Ant-style package patterns — for example, {@code "org.example.**"}. + *

+ * Multiple packages or patterns may be specified, either separately or within a single {@code String} — for + * example, {@code {"org.example.config", "org.example.service.**"}} or + * {@code "org.example.config, org.example.service.**"}. + *

+ * Use {@link #basePackageClasses} for a type-safe alternative to String-based package names. + * + * @see org.springframework.context.ConfigurableApplicationContext#CONFIG_LOCATION_DELIMITERS */ String[] basePackages() default {}; @@ -82,64 +96,60 @@ * 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. + * @since 3.4 + */ + Class nameGenerator() default BeanNameGenerator.class; + // JPA specific configuration /** * 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; @@ -161,7 +171,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; @@ -173,4 +182,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 queryEnhancerSelector() default QueryEnhancerSelector.DefaultQueryEnhancerSelector.class; + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/InspectionClassLoader.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/InspectionClassLoader.java index 7611ba3986..a95eb1db86 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/InspectionClassLoader.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/InspectionClassLoader.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaAuditingRegistrar.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaAuditingRegistrar.java index bafc70f0aa..88e4dbbd0c 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaAuditingRegistrar.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaAuditingRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. 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 b0a7851d42..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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -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/JpaRepositoriesRegistrar.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrar.java index 9e8d640da9..6c6e803576 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrar.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrar.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. 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 7188c0c184..e61f0e13ab 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -18,9 +18,12 @@ import static org.springframework.data.jpa.repository.config.BeanDefinitionNames.*; import jakarta.persistence.Entity; +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.MappedSuperclass; import jakarta.persistence.PersistenceContext; import jakarta.persistence.PersistenceUnit; +import jakarta.persistence.spi.PersistenceUnitInfo; import java.lang.annotation.Annotation; import java.util.Arrays; @@ -33,32 +36,46 @@ import java.util.Optional; import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import org.springframework.aot.generate.GenerationContext; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.aot.BeanRegistrationAotProcessor; import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.config.RuntimeBeanReference; import org.springframework.beans.factory.support.AbstractBeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.annotation.AnnotationConfigUtils; import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.env.Environment; 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.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.AbstractEntityManagerFactoryBean; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; import org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor; import org.springframework.util.ClassUtils; +import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** @@ -75,6 +92,7 @@ * @author Thomas Darimont * @author Christoph Strobl * @author Mark Paluch + * @author Hyunsang Han */ public class JpaRepositoryConfigExtension extends RepositoryConfigurationExtensionSupport { @@ -83,6 +101,7 @@ public class JpaRepositoryConfigExtension extends RepositoryConfigurationExtensi private static final String ENABLE_DEFAULT_TRANSACTIONS_ATTRIBUTE = "enableDefaultTransactions"; private static final String JPA_METAMODEL_CACHE_CLEANUP_CLASSNAME = "org.springframework.data.jpa.util.JpaMetamodelCacheCleanup"; private static final String ESCAPE_CHARACTER_PROPERTY = "escapeCharacter"; + private static final Logger log = LoggerFactory.getLogger(JpaRepositoryConfigExtension.class); private final Map entityManagerRefs = new LinkedHashMap<>(); @@ -91,6 +110,11 @@ public String getModuleName() { return "JPA"; } + @Override + public String getRepositoryBaseClassName() { + return SimpleJpaRepository.class.getName(); + } + @Override public String getRepositoryFactoryBeanClassName() { return JpaRepositoryFactoryBean.class.getName(); @@ -116,9 +140,17 @@ public void postProcess(BeanDefinitionBuilder builder, RepositoryConfigurationSo Optional 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.addPropertyValue("entityManager", + new RuntimeBeanReference(entityManagerRefs.get(source), EntityManager.class)); + } 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 @@ -167,10 +199,6 @@ public void registerBeansForRoot(BeanDefinitionRegistry registry, RepositoryConf Object source = config.getSource(); - registerLazyIfNotAlreadyRegistered( - () -> new RootBeanDefinition(EntityManagerBeanDefinitionRegistrarPostProcessor.class), registry, - EM_BEAN_DEFINITION_REGISTRAR_POST_PROCESSOR_BEAN_NAME, source); - registerLazyIfNotAlreadyRegistered(() -> new RootBeanDefinition(JpaMetamodelMappingContextFactoryBean.class), registry, JPA_MAPPING_CONTEXT_BEAN_NAME, source); @@ -185,7 +213,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, @@ -195,7 +222,7 @@ public void registerBeansForRoot(BeanDefinitionRegistry registry, RepositoryConf registerIfNotAlreadyRegistered(() -> { - Object value = AnnotationRepositoryConfigurationSource.class.isInstance(config) // + Object value = config instanceof AnnotationRepositoryConfigurationSource // ? config.getRequiredAttribute(ESCAPE_CHARACTER_PROPERTY, Character.class) // : config.getAttribute(ESCAPE_CHARACTER_PROPERTY).orElse("\\"); @@ -203,14 +230,20 @@ public void registerBeansForRoot(BeanDefinitionRegistry registry, RepositoryConf builder.addConstructorArgValue(value); return builder.getBeanDefinition(); - }, registry, JpaEvaluationContextExtension.class.getName(), source); } - private String registerSharedEntityMangerIfNotAlreadyRegistered(BeanDefinitionRegistry registry, + private void registerSharedEntityMangerIfNotAlreadyRegistered(BeanDefinitionRegistry registry, RepositoryConfigurationSource config) { String entityManagerBeanRef = getEntityManagerBeanRef(config); + String sharedEntityManagerBeanRef = lookupSharedEntityManagerBeanRef(entityManagerBeanRef, registry); + + if (sharedEntityManagerBeanRef != null) { + entityManagerRefs.put(config, sharedEntityManagerBeanRef); + return; + } + String entityManagerBeanName = "jpaSharedEM_" + entityManagerBeanRef; if (!registry.containsBeanDefinition(entityManagerBeanName)) { @@ -224,17 +257,49 @@ private String registerSharedEntityMangerIfNotAlreadyRegistered(BeanDefinitionRe } entityManagerRefs.put(config, entityManagerBeanName); - return entityManagerBeanName; + } + + private @Nullable String lookupSharedEntityManagerBeanRef(String entityManagerBeanRef, + BeanDefinitionRegistry registry) { + + if (!registry.containsBeanDefinition(entityManagerBeanRef)) { + return null; + } + + BeanDefinitionRegistry introspect = registry; + + if (introspect instanceof ConfigurableApplicationContext cac + && cac.getBeanFactory() instanceof BeanDefinitionRegistry br) { + introspect = br; + } + + if (!(introspect instanceof ConfigurableBeanFactory cbf)) { + return null; + } + + BeanDefinition beanDefinition = cbf.getMergedBeanDefinition(entityManagerBeanRef); + + if (ObjectUtils.isEmpty(beanDefinition.getBeanClassName())) { + return null; + } + + Class beanClass = org.springframework.data.util.ClassUtils.loadIfPresent(beanDefinition.getBeanClassName(), + getClass().getClassLoader()); + + // AbstractEntityManagerFactoryBean is able to create a SharedEntityManager + return beanClass != null && AbstractEntityManagerFactoryBean.class.isAssignableFrom(beanClass) + ? entityManagerBeanRef + : null; } @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; } /** @@ -297,6 +362,7 @@ static boolean isActive(@Nullable ClassLoader classLoader) { return AGENT_CLASSES.stream() // .anyMatch(agentClass -> ClassUtils.isPresent(agentClass, classLoader)); } + } /** @@ -308,8 +374,69 @@ 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. + public static final String USE_ENTITY_MANAGER = "spring.aot.jpa.repositories.use-entitymanager"; + + private static final String MODULE_NAME = "jpa"; + + @Override + protected void configureTypeContributions(AotRepositoryContext repositoryContext, + GenerationContext generationContext) { + super.configureTypeContributions(repositoryContext, generationContext); + } + + @Override + protected void configureTypeContribution(Class type, AotContext aotContext) { + aotContext.typeConfiguration(type, config -> config.contributeAccessors().forQuerydsl()); + } + + @Override + protected @Nullable JpaRepositoryContributor contributeAotRepository(AotRepositoryContext repositoryContext) { + + if (!repositoryContext.isGeneratedRepositoriesEnabled(MODULE_NAME)) { + return null; + } + + ConfigurableListableBeanFactory beanFactory = repositoryContext.getBeanFactory(); + Environment environment = repositoryContext.getEnvironment(); + boolean useEntityManager = environment.getProperty(USE_ENTITY_MANAGER, Boolean.class, false); + + if (useEntityManager) { + + Optional entityManagerFactoryRef = repositoryContext.getConfigurationSource() + .getAttribute("entityManagerFactoryRef"); + + log.debug( + "Using EntityManager '%s' for AOT repository generation".formatted(entityManagerFactoryRef.orElse(""))); + + EntityManagerFactory emf = entityManagerFactoryRef + .map(it -> beanFactory.getBean(it, EntityManagerFactory.class)) + .orElseGet(() -> beanFactory.getBean(EntityManagerFactory.class)); + return new JpaRepositoryContributor(repositoryContext, emf); + } + + ObjectProvider managedTypesProvider = beanFactory + .getBeanProvider(PersistenceManagedTypes.class); + PersistenceManagedTypes managedTypes = managedTypesProvider.getIfUnique(); + + if (managedTypes != null) { + + log.debug("Using PersistenceManagedTypes for AOT repository generation"); + return new JpaRepositoryContributor(repositoryContext, managedTypes); + } + + ObjectProvider infoProvider = beanFactory.getBeanProvider(PersistenceUnitInfo.class); + PersistenceUnitInfo unitInfo = infoProvider.getIfUnique(); + + if (unitInfo != null) { + + log.debug("Using PersistenceUnitInfo for AOT repository generation"); + return new JpaRepositoryContributor(repositoryContext, unitInfo); + } + + log.debug("Using scanned types for AOT repository generation"); + return new JpaRepositoryContributor(repositoryContext); } + } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryNameSpaceHandler.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryNameSpaceHandler.java index a0de895722..951e4132cd 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryNameSpaceHandler.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryNameSpaceHandler.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. 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 5742a1ea4e..ad0cafba95 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -23,15 +23,16 @@ import jakarta.persistence.TupleElement; import jakarta.persistence.TypedQuery; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; +import java.lang.reflect.Constructor; +import java.util.ArrayList; 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; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.EntityGraph; @@ -44,13 +45,16 @@ 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; /** * Abstract base class to implement {@link RepositoryQuery}s. @@ -97,12 +101,12 @@ public AbstractJpaQuery(JpaQueryMethod method, EntityManager em) { return new StreamExecution(); } else if (method.isProcedureQuery()) { return new ProcedureExecution(method.isCollectionQuery()); - } else if (method.isCollectionQuery()) { + } else if (method.isCollectionQuery() || method.isSearchQuery()) { return new CollectionExecution(); } 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 { @@ -116,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}. * @@ -134,10 +147,11 @@ protected JpaMetamodel getMetamodel() { return metamodel; } - @Nullable @Override - public Object execute(Object[] parameters) { - return doExecute(getExecution(), parameters); + public @Nullable Object execute(Object[] parameters) { + + JpaParametersParameterAccessor accessor = obtainParameterAccessor(parameters); + return doExecute(getExecution(accessor), accessor); } /** @@ -145,10 +159,8 @@ public Object execute(Object[] parameters) { * @param values * @return */ - @Nullable - private Object doExecute(JpaQueryExecution execution, Object[] values) { + private @Nullable Object doExecute(JpaQueryExecution execution, JpaParametersParameterAccessor accessor) { - JpaParametersParameterAccessor accessor = obtainParameterAccessor(values); Object result = execution.execute(this, accessor); ResultProcessor withDynamicProjection = method.getResultProcessor().withDynamicProjection(accessor); @@ -165,10 +177,17 @@ private JpaParametersParameterAccessor obtainParameterAccessor(Object[] values) return new JpaParametersParameterAccessor(method.getParameters(), values); } - protected JpaQueryExecution getExecution() { + protected JpaQueryExecution getExecution(JpaParametersParameterAccessor accessor) { JpaQueryExecution execution = this.execution.getNullable(); + if (method.isSearchQuery()) { + + ReturnedType returnedType = method.getResultProcessor().withDynamicProjection(accessor).getReturnedType(); + return new JpaQueryExecution.SearchResultExecution(execution == null ? new SingleEntityExecution() : execution, + returnedType, accessor.getScoringFunction(), accessor.normalizeSimilarity()); + } + if (execution != null) { return execution; } @@ -186,6 +205,8 @@ protected JpaQueryExecution getExecution() { * @param query * @return */ + @SuppressWarnings("NullAway") + @Contract("_, _ -> param1") protected T applyHints(T query, JpaQueryMethod method) { List hints = method.getHints(); @@ -235,8 +256,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) { @@ -256,7 +277,7 @@ private Query applyEntityGraphConfiguration(Query query, JpaQueryMethod method) JpaEntityGraph entityGraph = method.getEntityGraph(); if (entityGraph != null) { - QueryHints hints = Jpa21Utils.getFetchGraphHint(em, method.getEntityGraph(), + QueryHints hints = Jpa21Utils.getFetchGraphHint(em, entityGraph, getQueryMethod().getEntityInformation().getJavaType()); hints.forEach(query::setHint); @@ -276,16 +297,16 @@ 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; } - return returnedType.isProjecting() && !getMetamodel().isJpaManaged(returnedType.getReturnedType()) // - ? Tuple.class // - : null; + return returnedType.isProjecting() && returnedType.getReturnedType().isInterface() + && !getMetamodel().isJpaManaged(returnedType.getReturnedType()) // + ? Tuple.class // + : null; } /** @@ -304,12 +325,16 @@ protected Class getTypeToRead(ReturnedType returnedType) { */ protected abstract Query doCreateCountQuery(JpaParametersParameterAccessor accessor); - static class TupleConverter implements Converter { + public static class TupleConverter implements Converter { private final ReturnedType type; private final UnaryOperator tupleWrapper; + private final boolean dtoProjection; + + private final @Nullable PreferredConstructor preferredConstructor; + /** * Creates a new {@link TupleConverter} for the given {@link ReturnedType}. * @@ -331,7 +356,15 @@ 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(); + + if (this.dtoProjection) { + this.preferredConstructor = PreferredConstructorDiscoverer.discover(type.getReturnedType()); + } else { + this.preferredConstructor = null; + } } @Override @@ -352,184 +385,101 @@ public Object convert(Object source) { } } - return new TupleBackedMap(tupleWrapper.apply(tuple)); - } + if (dtoProjection) { - /** - * 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 { - - private static final String UNMODIFIABLE_MESSAGE = "A TupleBackedMap cannot be modified"; + Object[] ctorArgs = new Object[elements.size()]; + for (int i = 0; i < ctorArgs.length; i++) { + ctorArgs[i] = tuple.get(i); + } - private final Tuple tuple; + List> argTypes = getArgumentTypes(ctorArgs); - TupleBackedMap(Tuple tuple) { - this.tuple = tuple; - } + if (preferredConstructor != null && isConstructorCompatible(preferredConstructor.getConstructor(), argTypes)) { + return BeanUtils.instantiateClass(preferredConstructor.getConstructor(), ctorArgs); + } - @Override - public int size() { - return tuple.getElements().size(); + return BeanUtils.instantiateClass(getFirstMatchingConstructor(ctorArgs, argTypes), ctorArgs); } - @Override - public boolean isEmpty() { - return tuple.getElements().isEmpty(); - } + return new TupleBackedMap(tupleWrapper.apply(tuple)); + } - /** - * 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; - } - } + private Constructor getFirstMatchingConstructor(Object[] ctorArgs, List> argTypes) { - @Override - public boolean containsValue(Object value) { - return Arrays.asList(tuple.toArray()).contains(value); - } + for (Constructor ctor : type.getReturnedType().getDeclaredConstructors()) { - /** - * 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; + if (ctor.getParameterCount() != ctorArgs.length) { + continue; } - try { - return tuple.get((String) key); - } catch (IllegalArgumentException e) { - return null; + if (isConstructorCompatible(ctor, argTypes)) { + return ctor; } } - @Override - public Object put(String key, Object value) { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); - } - - @Override - public Object remove(Object key) { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); - } + throw new IllegalStateException(String.format( + "Cannot find compatible constructor for DTO projection '%s' accepting '%s'", type.getReturnedType().getName(), + argTypes.stream().map(Class::getName).collect(Collectors.joining(", ")))); + } - @Override - public void putAll(Map m) { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); - } + private static List> getArgumentTypes(Object[] ctorArgs) { + List> argTypes = new ArrayList<>(ctorArgs.length); - @Override - public void clear() { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); + for (Object ctorArg : ctorArgs) { + argTypes.add(ctorArg == null ? Void.class : ctorArg.getClass()); } + return argTypes; + } - @Override - public Set keySet() { + public static boolean isConstructorCompatible(Constructor constructor, List> argumentTypes) { - return tuple.getElements().stream() // - .map(TupleElement::getAlias) // - .collect(Collectors.toSet()); + if (constructor.getParameterCount() != argumentTypes.size()) { + return false; } - @Override - public Collection values() { - return Arrays.asList(tuple.toArray()); - } + for (int i = 0; i < argumentTypes.size(); i++) { - @Override - public Set> entrySet() { + MethodParameter methodParameter = MethodParameter.forExecutable(constructor, i); + Class argumentType = argumentTypes.get(i); - return tuple.getElements().stream() // - .map(e -> new HashMap.SimpleEntry(e.getAlias(), tuple.get(e))) // - .collect(Collectors.toSet()); + if (!areAssignmentCompatible(methodParameter.getParameterType(), argumentType)) { + return false; + } } + return true; } - } - private static class FallbackTupleWrapper implements Tuple { + private static boolean areAssignmentCompatible(Class to, Class from) { - private final Tuple delegate; - private final UnaryOperator fallbackNameTransformer = JdbcUtils::convertPropertyNameToUnderscoreName; + if (from == Void.class && !to.isPrimitive()) { + // treat Void as the bottom type, the class of null + return true; + } - FallbackTupleWrapper(Tuple delegate) { - this.delegate = delegate; - } + if (to.isPrimitive()) { - @Override - public X get(TupleElement tupleElement) { - return get(tupleElement.getAlias(), tupleElement.getJavaType()); - } + if (to == Short.TYPE) { + return from == Character.class || from == Byte.class; + } - @Override - public X get(String s, Class 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; + if (to == Integer.TYPE) { + return from == Short.class || from == Character.class || from == Byte.class; } - } - } - @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; + if (to == Long.TYPE) { + return from == Integer.class || from == Short.class || from == Character.class || from == Byte.class; } - } - } - @Override - public X get(int i, Class aClass) { - return delegate.get(i, aClass); - } + if (to == Double.TYPE) { + return from == Float.class; + } - @Override - public Object get(int i) { - return delegate.get(i); - } + return ClassUtils.isAssignable(to, from); + } - @Override - public Object[] toArray() { - return delegate.toArray(); + return ClassUtils.isAssignable(to, from); } - @Override - public List> 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 f219c374d2..09f54bbc7b 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -18,20 +18,24 @@ 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.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.QueryCreationException; 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.expression.spel.standard.SpelExpressionParser; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ConcurrentLruCache; -import org.springframework.util.StringUtils; /** * Base class for {@link String} based JPA queries. @@ -48,14 +52,15 @@ */ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery { - private final DeclaredQuery query; - private final Lazy countQuery; - private final QueryMethodEvaluationContextProvider evaluationContextProvider; - private final SpelExpressionParser parser; - private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache(); + private final EntityQuery query; + private final Map, Boolean> knownProjections = new ConcurrentHashMap<>(); + private final Lazy countQuery; + private final ValueExpressionDelegate valueExpressionDelegate; private final QueryRewriter queryRewriter; private final QuerySortRewriter querySortRewriter; private final Lazy countParameterBinder; + private final ValueEvaluationContextProvider valueExpressionContextProvider; + private final boolean hasDeclaredCountQuery; /** * Creates a new {@link AbstractStringBasedJpaQuery} from the given {@link JpaQueryMethod}, {@link EntityManager} and @@ -64,70 +69,122 @@ 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 evaluationContextProvider must not be {@literal null}. - * @param parser must not be {@literal null}. - * @param queryRewriter must not be {@literal null}. + * @param queryConfiguration must not be {@literal null}. + */ + 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, String queryString, - @Nullable String countQueryString, QueryRewriter queryRewriter, - QueryMethodEvaluationContextProvider evaluationContextProvider, SpelExpressionParser parser) { + 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(evaluationContextProvider, "ExpressionEvaluationContextProvider must not be null"); - Assert.notNull(parser, "Parser 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.evaluationContextProvider = evaluationContextProvider; - this.query = new ExpressionBasedStringQuery(queryString, method.getEntityInformation(), parser, - method.isNativeQuery()); + this.valueExpressionDelegate = queryConfiguration.getValueExpressionDelegate(); + this.valueExpressionContextProvider = valueExpressionDelegate.createValueContextProvider(method.getParameters()); - 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(), parser, - method.isNativeQuery()); + if (countQuery != null) { + return TemplatedQuery.create(countQuery, method.getEntityInformation(), queryConfiguration); } - return query.deriveCountQuery(method.getCountQueryProjection()); + return this.query.deriveCountQuery(method.getCountQueryProjection()); }); - this.countParameterBinder = Lazy.of(() -> { - return this.createBinder(this.countQuery.get()); - }); - - this.parser = parser; - this.queryRewriter = queryRewriter; + this.countParameterBinder = Lazy.of(() -> this.createBinder(this.countQuery.get())); + this.queryRewriter = queryConfiguration.getQueryRewriter(method); JpaParameters parameters = method.getParameters(); - if (parameters.hasPageableParameter() || parameters.hasSortParameter()) { - this.querySortRewriter = new CachingQuerySortRewriter(); + + if (parameters.hasDynamicProjection()) { + this.querySortRewriter = SimpleQuerySortRewriter.INSTANCE; } else { - this.querySortRewriter = NoOpQuerySortRewriter.INSTANCE; + if (parameters.hasPageableParameter() || parameters.hasSortParameter()) { + this.querySortRewriter = new CachingQuerySortRewriter(); + } else { + this.querySortRewriter = new UnsortedCachingQuerySortRewriter(); + } } - Assert.isTrue(method.isNativeQuery() || !query.usesJdbcStyleParameters(), - "JDBC style parameters (?) are not supported for JPA queries"); + if (!method.isNativeQuery() && this.query.usesJdbcStyleParameters()) { + throw QueryCreationException.create(method, "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(); - String sortedQueryString = querySortRewriter.getSorted(query, sort); - ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor); + ReturnedType returnedType = getReturnedType(processor); + QueryProvider sortedQuery = getSortedQuery(sort, returnedType); + Query query = createJpaQuery(sortedQuery, sort, accessor.getPageable(), returnedType); - Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), processor.getReturnedType()); + // 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, accessor); + } + + /** + * Post-process {@link ReturnedType} to determine if the query is projecting by checking the projection and property + * assignability. + * + * @param processor + * @return + */ + ReturnedType getReturnedType(ResultProcessor processor) { - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(sortedQueryString, query); + ReturnedType returnedType = processor.getReturnedType(); + Class returnedJavaType = returnedType.getReturnedType(); - // 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); + if (!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() || getMetamodel().isJpaManaged(returnedJavaType) + || !returnedType.needsCustomConstruction()) { + if (known == null) { + knownProjections.put(returnedJavaType, false); + } + return new NonProjectingReturnedType(returnedType); + } + + knownProjections.put(returnedJavaType, true); + return returnedType; + } + + QueryProvider getSortedQuery(Sort sort, ReturnedType returnedType) { + return querySortRewriter.getSorted(query, sort, returnedType); } @Override @@ -135,9 +192,9 @@ protected ParameterBinder createBinder() { return createBinder(query); } - protected ParameterBinder createBinder(DeclaredQuery query) { - return ParameterBinderFactory.createQueryAwareBinder(getQueryMethod().getParameters(), query, parser, - evaluationContextProvider); + protected ParameterBinder createBinder(ParametrizedQuery query) { + return ParameterBinderFactory.createQueryAwareBinder(getQueryMethod().getParameters(), query, + valueExpressionDelegate, valueExpressionContextProvider); } @Override @@ -146,13 +203,14 @@ protected Query doCreateCountQuery(JpaParametersParameterAccessor accessor) { String queryString = countQuery.get().getQueryString(); EntityManager em = getEntityManager(); - Query query = getQueryMethod().isNativeQuery() // - ? em.createNativeQuery(queryString) // - : em.createQuery(queryString, Long.class); + String queryStringToUse = potentiallyRewriteQuery(queryString, accessor.getSort(), accessor.getPageable()); - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(queryString, query); + Query query = getQueryMethod().isNativeQuery() // + ? em.createNativeQuery(queryStringToUse) // + : em.createQuery(queryStringToUse, Long.class); - countParameterBinder.get().bind(metadata.withQuery(query), accessor, QueryParameterSetter.ErrorHandling.LENIENT); + countParameterBinder.get().bind(new QueryParameterSetter.BindableQuery(query), accessor, + QueryParameterSetter.ErrorHandling.LENIENT); return query; } @@ -160,14 +218,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(); } @@ -175,20 +233,21 @@ 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(query.getQueryString(), sort, pageable); if (this.query.hasConstructorExpression() || this.query.isDefaultProjection()) { - return em.createQuery(potentiallyRewriteQuery(queryString, sort, pageable)); + return em.createQuery(queryToUse); } Class typeToRead = getTypeToRead(returnedType); return typeToRead == null // - ? em.createQuery(potentiallyRewriteQuery(queryString, sort, pageable)) // - : em.createQuery(potentiallyRewriteQuery(queryString, sort, pageable), typeToRead); + ? em.createQuery(queryToUse) // + : em.createQuery(queryToUse, typeToRead); } /** @@ -207,32 +266,47 @@ protected String potentiallyRewriteQuery(String originalQuery, Sort sort, @Nulla : queryRewriter.rewrite(originalQuery, sort); } - String applySorting(CachableQuery cachableQuery) { - - return QueryEnhancerFactory.forQuery(cachableQuery.getDeclaredQuery()).applySorting(cachableQuery.getSort(), - cachableQuery.getAlias()); + QueryProvider applySorting(CachableQuery cachableQuery) { + return cachableQuery.getDeclaredQuery() + .rewrite(new DefaultQueryRewriteInformation(cachableQuery.getSort(), cachableQuery.getReturnedType())); } /** * Query Sort Rewriter interface. */ interface QuerySortRewriter { - String getSorted(DeclaredQuery query, Sort sort); + QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType); } /** * No-op query rewriter. */ - enum NoOpQuerySortRewriter implements QuerySortRewriter { + enum SimpleQuerySortRewriter implements QuerySortRewriter { + INSTANCE; - public String getSorted(DeclaredQuery query, Sort sort) { + public QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType) { + return query.rewrite(new DefaultQueryRewriteInformation(sort, returnedType)); + } + } + + static class UnsortedCachingQuerySortRewriter implements QuerySortRewriter { + + private volatile @Nullable QueryProvider cachedQuery; + + public QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType) { if (sort.isSorted()) { throw new UnsupportedOperationException("NoOpQueryCache does not support sorting"); } - return query.getQueryString(); + QueryProvider cachedQuery = this.cachedQuery; + if (cachedQuery == null) { + this.cachedQuery = cachedQuery = query + .rewrite(new DefaultQueryRewriteInformation(sort, returnedType)); + } + + return cachedQuery; } } @@ -241,17 +315,25 @@ public String getSorted(DeclaredQuery query, Sort sort) { */ class CachingQuerySortRewriter implements QuerySortRewriter { - private final ConcurrentLruCache queryCache = new ConcurrentLruCache<>(16, + private final ConcurrentLruCache queryCache = new ConcurrentLruCache<>(16, AbstractStringBasedJpaQuery.this::applySorting); + private volatile @Nullable QueryProvider cachedQuery; + @Override - public String getSorted(DeclaredQuery query, Sort sort) { + public QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType) { if (sort.isUnsorted()) { - return query.getQueryString(); + + QueryProvider cachedQuery = this.cachedQuery; + if (cachedQuery == null) { + this.cachedQuery = cachedQuery = queryCache.get(new CachableQuery(query, sort, returnedType)); + } + + return cachedQuery; } - return queryCache.get(new CachableQuery(query, sort)); + return queryCache.get(new CachableQuery(query, sort, returnedType)); } } @@ -264,28 +346,29 @@ public String getSorted(DeclaredQuery query, Sort sort) { */ 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) { + 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() { return sort; } - @Nullable - String getAlias() { - return declaredQuery.getAlias(); + public ReturnedType getReturnedType() { + return returnedType; } @Override @@ -314,4 +397,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 getInputProperties() { + return delegate.getInputProperties(); + } + } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarErrorListener.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarErrorListener.java index b629bd6179..dd3eab6412 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarErrorListener.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarErrorListener.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -15,10 +15,17 @@ */ package org.springframework.data.jpa.repository.query; +import java.util.List; + import org.antlr.v4.runtime.BaseErrorListener; +import org.antlr.v4.runtime.CommonToken; +import org.antlr.v4.runtime.InputMismatchException; +import org.antlr.v4.runtime.NoViableAltException; import org.antlr.v4.runtime.RecognitionException; import org.antlr.v4.runtime.Recognizer; +import org.springframework.util.ObjectUtils; + /** * A {@link BaseErrorListener} that will throw a {@link BadJpqlGrammarException} if the query is invalid. * @@ -29,14 +36,62 @@ class BadJpqlGrammarErrorListener extends BaseErrorListener { private final String query; + private final String grammar; + BadJpqlGrammarErrorListener(String query) { + this(query, "JPQL"); + } + + BadJpqlGrammarErrorListener(String query, String grammar) { this.query = query; + this.grammar = grammar; } @Override public void syntaxError(Recognizer recognizer, Object offendingSymbol, int line, int charPositionInLine, String msg, RecognitionException e) { - throw new BadJpqlGrammarException("Line " + line + ":" + charPositionInLine + " " + msg, query, null); + throw new BadJpqlGrammarException(formatMessage(offendingSymbol, line, charPositionInLine, msg, e, query), grammar, + query, null); + } + + /** + * Rewrite the error message. + */ + private static String formatMessage(Object offendingSymbol, int line, int charPositionInLine, String message, + RecognitionException e, String query) { + + String errorText = "At " + line + ":" + charPositionInLine; + + if (offendingSymbol instanceof CommonToken ct) { + + String token = ct.getText(); + if (!ObjectUtils.isEmpty(token)) { + errorText += " and token '" + token + "'"; + } + } + errorText += ", "; + + if (e instanceof NoViableAltException) { + + errorText += message.substring(0, message.indexOf('\'')); + if (query.isEmpty()) { + errorText += "'*' (empty query string)"; + } else { + + List list = query.lines().toList(); + String lineText = list.get(line - 1); + String text = lineText.substring(0, charPositionInLine) + "*" + lineText.substring(charPositionInLine); + errorText += "'" + text + "'"; + } + + } else if (e instanceof InputMismatchException) { + errorText += message.substring(0, message.length() - 1).replace(" expecting {", + ", expecting one of the following tokens: "); + } else { + errorText += message; + } + + return errorText; } } 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 56dae97430..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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -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,8 +30,12 @@ public class BadJpqlGrammarException extends InvalidDataAccessResourceUsageExcep private final String jpql; - public BadJpqlGrammarException(String message, String jpql, @Nullable Throwable cause) { - super(message + "; Bad JPQL grammar [" + jpql + "]", cause); + public BadJpqlGrammarException(@Nullable String message, String jpql, @Nullable Throwable cause) { + this(message, jpql, "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/BeanFactoryQueryRewriterProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BeanFactoryQueryRewriterProvider.java index 552cca806c..ee50c94696 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BeanFactoryQueryRewriterProvider.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BeanFactoryQueryRewriterProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/CollectionUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/CollectionUtils.java index 632125c566..6352836f98 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/CollectionUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/CollectionUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. 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 4e54424404..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 @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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,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. + *

+ * 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 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 true if native query otherwise false - */ - 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..5a7c255b46 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultEntityQuery.java @@ -0,0 +1,174 @@ +/* + * 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; + +import org.springframework.data.util.Lazy; + +/** + * Encapsulation of a JPA query string, typically returning entities or DTOs. Provides access to parameter bindings. + *

+ * 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 Lazy queryString; + private final QueryEnhancer queryEnhancer; + + DefaultEntityQuery(PreprocessedQuery query, QueryEnhancerFactory queryEnhancerFactory) { + this.query = query; + this.queryEnhancer = queryEnhancerFactory.create(query); + this.queryString = Lazy.of(() -> queryEnhancer.getQuery().getQueryString()); + } + + @Override + public T doWithEnhancer(Function function) { + return function.apply(queryEnhancer); + } + + @Override + public boolean isNative() { + return query.isNative(); + } + + @Override + public String getQueryString() { + return queryString.get(); + } + + @Override + public PreprocessedQuery getQuery() { + return query; + } + + /** + * 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 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 getParameterBindings() { + return query.getBindings(); + } + + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultJpaEntityMetadata.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultJpaEntityMetadata.java index 78105e7eb0..429b499a43 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultJpaEntityMetadata.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultJpaEntityMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -17,6 +17,10 @@ import jakarta.persistence.Entity; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -30,6 +34,7 @@ public class DefaultJpaEntityMetadata implements JpaEntityMetadata { private final Class domainType; + private final @Nullable Entity entity; /** * Creates a new {@link DefaultJpaEntityMetadata} for the given domain type. @@ -39,7 +44,9 @@ public class DefaultJpaEntityMetadata implements JpaEntityMetadata { public DefaultJpaEntityMetadata(Class domainType) { Assert.notNull(domainType, "Domain type must not be null"); + this.domainType = domainType; + this.entity = AnnotatedElementUtils.findMergedAnnotation(domainType, Entity.class); } @Override @@ -49,8 +56,20 @@ public Class getJavaType() { @Override public String getEntityName() { + return getEntityNameOr(DefaultJpaEntityMetadata::unqualify); + } - Entity entity = AnnotatedElementUtils.findMergedAnnotation(domainType, Entity.class); - return null != entity && StringUtils.hasText(entity.name()) ? entity.name() : domainType.getSimpleName(); + private String getEntityNameOr(Function, String> alternative) { + return (entity != null && StringUtils.hasText(entity.name())) ? entity.name() : alternative.apply(domainType); } + + static String unqualify(Class clazz) { + return unqualify(clazz.getName()); + } + + static String unqualify(String qualifiedName) { + int loc = qualifiedName.lastIndexOf('.'); + return loc < 0 ? qualifiedName : qualifiedName.substring(loc + 1); + } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultJpaQueryMethodFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultJpaQueryMethodFactory.java index 03bcb52b83..80539f2fac 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultJpaQueryMethodFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultJpaQueryMethodFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2024 the original author or authors. + * Copyright 2019-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. 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 3053bb95f5..0092dd5e72 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -15,52 +15,58 @@ */ 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 {@link QueryEnhancer} using {@link QueryUtils}. + * The implementation of the Regex-based {@link QueryEnhancer} using {@link QueryUtils}. * * @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 @Nullable String alias; + private final String projection; - 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()); } @Override - public String applySorting(Sort sort, @Nullable String alias) { - return QueryUtils.applySorting(this.query.getQueryString(), sort, alias); + public String rewrite(QueryRewriteInformation rewriteInformation) { + return QueryUtils.applySorting(this.query.getQueryString(), rewriteInformation.getSort(), alias); } @Override - public String detectAlias() { - return QueryUtils.detectAlias(this.query.getQueryString()); + public String createCountQueryFor(@Nullable String countProjection) { + + boolean nativeQuery = this.query instanceof DeclaredQuery dc ? dc.isNative() : true; + return QueryUtils.createCountQueryFor(this.query.getQueryString(), countProjection, nativeQuery); } @Override - public String createCountQueryFor(@Nullable String countProjection) { - return QueryUtils.createCountQueryFor(this.query.getQueryString(), countProjection, this.query.isNativeQuery()); + public boolean hasConstructorExpression() { + return this.hasConstructorExpression; } @Override - public String getProjection() { - return QueryUtils.getProjection(this.query.getQueryString()); + public @Nullable String detectAlias() { + return this.alias; } @Override - public Set getJoinAliases() { - return QueryUtils.getOuterJoinAliases(this.query.getQueryString()); + public String getProjection() { + return this.projection; } @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/DefaultQueryRewriteInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryRewriteInformation.java new file mode 100644 index 0000000000..f4f2496621 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryRewriteInformation.java @@ -0,0 +1,37 @@ +/* + * 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 org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.ReturnedType; + +/** + * Default {@link org.springframework.data.jpa.repository.query.QueryEnhancer.QueryRewriteInformation} implementation. + * + * @author Mark Paluch + */ +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/query/DelegatingQueryRewriter.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DelegatingQueryRewriter.java index e171a49413..8cba903a94 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DelegatingQueryRewriter.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DelegatingQueryRewriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. 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 new file mode 100644 index 0000000000..28de9ba657 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java @@ -0,0 +1,133 @@ +/* + * 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.Iterator; +import java.util.List; +import java.util.function.Function; + +import org.springframework.data.repository.query.ReturnedType; + +/** + * HQL Query Transformer that rewrites the query using constructor expressions. + *

+ * Query rewriting from a plain property/object selection towards constructor expression only works if either: + *

    + *
  • The query selects its primary alias ({@code SELECT p FROM Person p})
  • + *
  • The query specifies a property list ({@code SELECT p.foo, p.bar FROM Person p}, + * {@code SELECT COUNT(p.foo), p.bar AS bar FROM Person p})
  • + *
+ * + * @author Mark Paluch + * @since 3.5 + */ +class DtoProjectionTransformerDelegate { + + private final ReturnedType returnedType; + private final boolean applyRewriting; + private final List selectItems = new ArrayList<>(); + + public DtoProjectionTransformerDelegate(ReturnedType returnedType) { + this.returnedType = returnedType; + this.applyRewriting = returnedType.isProjecting() && !returnedType.getReturnedType().isInterface() + && returnedType.needsCustomConstruction(); + } + + public boolean applyRewriting() { + return applyRewriting; + } + + public boolean canRewrite() { + return applyRewriting() && !selectItems.isEmpty(); + } + + public void appendSelectItem(QueryTokenStream selectItem) { + + if (applyRewriting()) { + selectItems.add(new DetachedStream(selectItem)); + } + } + + public QueryTokenStream getRewrittenSelectionList() { + + if (canRewrite()) { + + QueryRenderer.QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(QueryTokens.TOKEN_NEW); + builder.append(QueryTokens.token(returnedType.getReturnedType().getName())); + builder.append(QueryTokens.TOKEN_OPEN_PAREN); + + if (selectItems.size() == 1 && selectItems.get(0).size() == 1) { + + builder.appendInline(QueryTokenStream.concat(returnedType.getInputProperties(), property -> { + + QueryRenderer.QueryRendererBuilder prop = QueryRenderer.builder(); + prop.appendInline(selectItems.get(0)); + prop.append(QueryTokens.TOKEN_DOT); + prop.append(QueryTokens.token(property)); + + return prop.build(); + }, QueryTokens.TOKEN_COMMA)); + } else { + builder.append(QueryTokenStream.concat(selectItems, Function.identity(), TOKEN_COMMA)); + } + + builder.append(TOKEN_CLOSE_PAREN); + + return builder.build(); + } + + return QueryTokenStream.empty(); + } + + private static class DetachedStream extends QueryRenderer { + + private final QueryTokenStream delegate; + + private DetachedStream(QueryTokenStream delegate) { + this.delegate = delegate; + } + + @Override + public boolean isExpression() { + return delegate.isExpression(); + } + + @Override + public int size() { + return delegate.size(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public Iterator iterator() { + return delegate.iterator(); + } + + @Override + public String render() { + return delegate instanceof QueryRenderer ? ((QueryRenderer) delegate).render() : delegate.toString(); + } + } + +} 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 55% 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 67f9f9b3e6..bfb18a5c8d 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 @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. @@ -17,21 +17,32 @@ 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 { - /** - * An implementation implementing the NULL-Object pattern for situations where there is no query. - */ - static final DeclaredQuery EMPTY_QUERY = new EmptyDeclaredQuery(); + INSTANCE; + + EmptyIntrospectedQuery() {} + + @Override + public boolean hasParameterBindings() { + return false; + } + + @Override + public boolean usesJdbcStyleParameters() { + return false; + } @Override public boolean hasNamedParameter() { @@ -39,12 +50,17 @@ public boolean hasNamedParameter() { } @Override - public String getQueryString() { - return ""; + public List getParameterBindings() { + return Collections.emptyList(); + } + + public @Nullable String getAlias() { + return null; } @Override - public String getAlias() { + @SuppressWarnings("NullAway") + public T doWithEnhancer(Function function) { return null; } @@ -53,23 +69,40 @@ public boolean hasConstructorExpression() { return false; } + @Override + public boolean isNative() { + return false; + } + @Override public boolean isDefaultProjection() { return false; } @Override - public List 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 PreprocessedQuery getQuery() { + throw new UnsupportedOperationException(); } + + @Override + public String toString() { + return ""; + } + } 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 new file mode 100644 index 0000000000..1b05738d5e --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyQueryTokenStream.java @@ -0,0 +1,64 @@ +/* + * 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.Collections; +import java.util.Iterator; + +import org.jspecify.annotations.Nullable; + +/** + * Empty QueryTokenStream. + * + * @author Mark Paluch + * @since 3.4 + */ +class EmptyQueryTokenStream implements QueryTokenStream { + + static final EmptyQueryTokenStream INSTANCE = new EmptyQueryTokenStream(); + + private EmptyQueryTokenStream() {} + + @Override + public @Nullable QueryToken getFirst() { + return null; + } + + @Override + public @Nullable QueryToken getLast() { + return null; + } + + @Override + public boolean isExpression() { + return false; + } + + @Override + public int size() { + return 0; + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } +} 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..5753731a6e --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EntityQuery.java @@ -0,0 +1,106 @@ +/* + * 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. + *

+ * 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 doWithEnhancer(Function 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; + } + + PreprocessedQuery getQuery(); + + /** + * 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 new file mode 100644 index 0000000000..9b4f06b381 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlCountQueryTransformer.java @@ -0,0 +1,165 @@ +/* + * 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.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.util.StringUtils; + +/** + * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed EQL query into a + * {@code COUNT(…)} query. + * + * @author Greg Turnquist + * @author Mark Paluch + * @author Christoph Strobl + * @since 3.4 + */ +@SuppressWarnings({ "ConstantValue", "NullAway" }) +class EqlCountQueryTransformer extends EqlQueryRenderer { + + private final @Nullable String countProjection; + private final @Nullable String primaryFromAlias; + + EqlCountQueryTransformer(@Nullable String countProjection, QueryInformation queryInformation) { + this.countProjection = countProjection; + this.primaryFromAlias = queryInformation.getAlias(); + } + + @Override + public QueryTokenStream visitSelectQuery(EqlParser.SelectQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.appendExpression(visit(ctx.select_clause())); + builder.appendExpression(visit(ctx.from_clause())); + + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); + } + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } + + return builder; + } + + @Override + public QueryTokenStream visitFromQuery(EqlParser.FromQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + QueryRendererBuilder countBuilder = QueryRenderer.builder(); + countBuilder.append(TOKEN_SELECT_COUNT); + + if (countProjection != null) { + countBuilder.append(QueryTokens.token(countProjection)); + } else { + if (primaryFromAlias == null) { + countBuilder.append(TOKEN_DOUBLE_UNDERSCORE); + } else { + countBuilder.append(QueryTokens.token(primaryFromAlias)); + } + } + + countBuilder.append(TOKEN_CLOSE_PAREN); + + builder.appendExpression(countBuilder); + + if (ctx.from_clause() != null) { + builder.appendExpression(visit(ctx.from_clause())); + if (primaryFromAlias == null) { + builder.append(TOKEN_AS); + builder.append(TOKEN_DOUBLE_UNDERSCORE); + } + } + + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); + } + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } + + return builder; + } + + @Override + public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { + + boolean usesDistinct = ctx.DISTINCT() != null; + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(QueryTokens.expression(ctx.SELECT())); + builder.append(TOKEN_COUNT_FUNC); + + QueryRendererBuilder nested = QueryRenderer.builder(); + if (countProjection == null) { + if (usesDistinct) { + nested.append(QueryTokens.expression(ctx.DISTINCT())); + nested.append(getDistinctCountSelection(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA))); + } else if (StringUtils.hasText(primaryFromAlias)) { + nested.append(QueryTokens.token(primaryFromAlias)); + } else { + if (ctx.select_item().isEmpty()) { + // cannot happen as per grammar, but you never know… + nested.append(QueryTokens.token("1")); + } else { + nested.append(visit(ctx.select_item().get(0))); + } + } + } else { + if (usesDistinct) { + nested.append(QueryTokens.expression(ctx.DISTINCT())); + } + nested.append(QueryTokens.token(countProjection)); + } + + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); + return builder; + } + + private QueryTokenStream getDistinctCountSelection(QueryTokenStream selectionListbuilder) { + + QueryRendererBuilder nested = new QueryRendererBuilder(); + CountSelectionTokenStream countSelection = CountSelectionTokenStream.create(selectionListbuilder); + + if (countSelection.requiresPrimaryAlias()) { + // constructor + if (primaryFromAlias == null) { + throw new IllegalStateException( + "Primary alias must be set for DISTINCT count selection using constructor expressions"); + } + nested.append(QueryTokens.token(primaryFromAlias)); + } else { + // keep all the select items to distinct against + nested.append(countSelection); + } + return nested; + } +} 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 new file mode 100644 index 0000000000..e8fb0a9fdc --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryIntrospector.java @@ -0,0 +1,89 @@ +/* + * 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; + +/** + * {@link ParsedQueryIntrospector} for EQL queries. + * + * @author Mark Paluch + * @author Christoph Strobl + * @author Soomin Kim + */ +@SuppressWarnings("UnreachableCode") +class EqlQueryIntrospector extends EqlBaseVisitor implements ParsedQueryIntrospector { + + private final EqlQueryRenderer renderer = new EqlQueryRenderer(); + private final QueryInformationHolder introspection = new QueryInformationHolder(); + + @Override + public QueryInformation getParsedQueryInformation() { + return new QueryInformation(introspection); + } + + @Override + public Void visitSelectQuery(EqlParser.SelectQueryContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.SELECT); + return super.visitSelectQuery(ctx); + } + + @Override + public Void visitFromQuery(EqlParser.FromQueryContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.SELECT); + return super.visitFromQuery(ctx); + } + + @Override + public Void visitUpdate_statement(EqlParser.Update_statementContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.UPDATE); + return super.visitUpdate_statement(ctx); + } + + @Override + public Void visitDelete_statement(EqlParser.Delete_statementContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.DELETE); + return super.visitDelete_statement(ctx); + } + + @Override + public Void visitSelect_clause(EqlParser.Select_clauseContext ctx) { + + introspection.captureProjection(ctx.select_item(), renderer::visitSelect_item); + return super.visitSelect_clause(ctx); + } + + @Override + public Void visitRange_variable_declaration(EqlParser.Range_variable_declarationContext ctx) { + + if (ctx.identification_variable() != null && !EqlQueryRenderer.isSubquery(ctx) + && !EqlQueryRenderer.isSetQuery(ctx)) { + introspection.capturePrimaryAlias(ctx.identification_variable().getText()); + } + + return super.visitRange_variable_declaration(ctx); + } + + @Override + public Void visitConstructor_expression(EqlParser.Constructor_expressionContext ctx) { + + introspection.constructorExpressionPresent(); + return super.visitConstructor_expression(ctx); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryParser.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryParser.java deleted file mode 100644 index 4e5dadff34..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryParser.java +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright 2023-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; - -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.ParserRuleContext; -import org.springframework.data.domain.Sort; -import org.springframework.lang.Nullable; - -/** - * Implements the {@code EQL} parsing operations of a {@link JpaQueryParserSupport} using the ANTLR-generated - * {@link EqlParser} and {@link EqlQueryTransformer}. - * - * @author Greg Turnquist - * @since 3.2 - */ -class EqlQueryParser extends JpaQueryParserSupport { - - EqlQueryParser(String query) { - super(query); - } - - /** - * Convenience method to parse a EQL query. Will throw a {@link BadJpqlGrammarException} if the query is invalid. - * - * @param query - * @return a parsed query, ready for postprocessing - */ - public static ParserRuleContext parseQuery(String query) { - - EqlLexer lexer = new EqlLexer(CharStreams.fromString(query)); - EqlParser parser = new EqlParser(new CommonTokenStream(lexer)); - - configureParser(query, lexer, parser); - - return parser.start(); - } - - /** - * Parse the query using {@link #parseQuery(String)}. - * - * @return a parsed query - */ - @Override - protected ParserRuleContext parse(String query) { - return parseQuery(query); - } - - /** - * Use the {@link EqlQueryTransformer} to transform the original query into a query with the {@link Sort} applied. - * - * @param parsedQuery - * @param sort can be {@literal null} - * @return list of {@link JpaQueryParsingToken}s - */ - @Override - protected List applySort(ParserRuleContext parsedQuery, Sort sort) { - return new EqlQueryTransformer(sort).visit(parsedQuery); - } - - /** - * Use the {@link EqlQueryTransformer} to transform the original query into a count query. - * - * @param parsedQuery - * @param countProjection - * @return list of {@link JpaQueryParsingToken}s - */ - @Override - protected List doCreateCountQuery(ParserRuleContext parsedQuery, - @Nullable String countProjection) { - return new EqlQueryTransformer(true, countProjection).visit(parsedQuery); - } - - /** - * Run the parsed query through {@link EqlQueryTransformer} to find the primary FROM clause's alias. - * - * @param parsedQuery - * @return can be {@literal null} - */ - @Override - protected String doFindAlias(ParserRuleContext parsedQuery) { - - EqlQueryTransformer transformVisitor = new EqlQueryTransformer(); - transformVisitor.visit(parsedQuery); - return transformVisitor.getAlias(); - } - - /** - * Use {@link EqlQueryTransformer} to find the projection of the query. - * - * @param parsedQuery - * @return - */ - @Override - protected List doFindProjection(ParserRuleContext parsedQuery) { - - EqlQueryTransformer transformVisitor = new EqlQueryTransformer(); - transformVisitor.visit(parsedQuery); - return transformVisitor.getProjection(); - } - - /** - * Use {@link EqlQueryTransformer} to detect if the query uses a {@code new com.example.Dto()} DTO constructor in the - * primary select clause. - * - * @param parsedQuery - * @return Guaranteed to be {@literal true} or {@literal false}. - */ - @Override - protected boolean doCheckForConstructor(ParserRuleContext parsedQuery) { - - EqlQueryTransformer transformVisitor = new EqlQueryTransformer(); - transformVisitor.visit(parsedQuery); - return transformVisitor.hasConstructorExpression(); - } -} 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 f3ecafe01e..f88cfa3e23 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -15,2543 +15,978 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; +import static org.springframework.data.jpa.repository.query.QueryTokens.*; import java.util.ArrayList; import java.util.List; +import org.antlr.v4.runtime.ParserRuleContext; +import org.antlr.v4.runtime.RuleContext; +import org.antlr.v4.runtime.tree.ParseTree; +import org.antlr.v4.runtime.tree.RuleNode; +import org.antlr.v4.runtime.tree.TerminalNode; + +import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.util.CollectionUtils; + /** * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that renders an EQL query without making any changes. * * @author Greg Turnquist * @author Christoph Strobl + * @author Mark Paluch + * @author TaeHyun Kang * @since 3.2 */ @SuppressWarnings({ "ConstantConditions", "DuplicatedCode" }) -class EqlQueryRenderer extends EqlBaseVisitor> { - - @Override - public List visitStart(EqlParser.StartContext ctx) { - return visit(ctx.ql_statement()); - } - - @Override - public List visitQl_statement(EqlParser.Ql_statementContext ctx) { - - if (ctx.select_statement() != null) { - return visit(ctx.select_statement()); - } else if (ctx.update_statement() != null) { - return visit(ctx.update_statement()); - } else if (ctx.delete_statement() != null) { - return visit(ctx.delete_statement()); - } else { - return List.of(); - } - } - - @Override - public List visitSelect_statement(EqlParser.Select_statementContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.select_clause())); - tokens.addAll(visit(ctx.from_clause())); - - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); - } +class EqlQueryRenderer extends EqlBaseVisitor { - if (ctx.groupby_clause() != null) { - tokens.addAll(visit(ctx.groupby_clause())); - } + /** + * Is this AST tree a {@literal subquery}? + * + * @return {@literal true} is the query is a subquery; {@literal false} otherwise. + */ + static boolean isSubquery(ParserRuleContext ctx) { - if (ctx.having_clause() != null) { - tokens.addAll(visit(ctx.having_clause())); - } + while (ctx != null) { - if (ctx.orderby_clause() != null) { - tokens.addAll(visit(ctx.orderby_clause())); - } + if (ctx instanceof EqlParser.SubqueryContext) { + return true; + } - for (int i = 0; i < ctx.setOperator().size(); i++) { + if (ctx instanceof EqlParser.Update_statementContext || ctx instanceof EqlParser.Delete_statementContext) { + return false; + } - tokens.addAll(visit(ctx.setOperator(i))); - tokens.addAll(visit(ctx.select_statement(i))); + ctx = ctx.getParent(); } - return tokens; + return false; } - @Override - public List visitSetOperator(EqlParser.SetOperatorContext ctx) { + /** + * Is this AST tree a {@literal set} query that has been added through {@literal UNION|INTERSECT|EXCEPT}? + * + * @return boolean + */ + static boolean isSetQuery(ParserRuleContext ctx) { - List tokens = new ArrayList<>(); + while (ctx != null) { - if (ctx.UNION() != null) { - tokens.add(new JpaQueryParsingToken(ctx.UNION())); - } else if (ctx.INTERSECT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INTERSECT())); - } else if (ctx.EXCEPT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.EXCEPT())); - } + if (ctx instanceof EqlParser.Set_fuctionContext) { + return true; + } - if (ctx.ALL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ALL())); + ctx = ctx.getParent(); } - return tokens; + return false; } @Override - public List visitUpdate_statement(EqlParser.Update_statementContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.update_clause())); - - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); - } - - return tokens; + public QueryTokenStream visitStart(EqlParser.StartContext ctx) { + return visit(ctx.ql_statement()); } @Override - public List visitDelete_statement(EqlParser.Delete_statementContext ctx) { + public QueryTokenStream visitFrom_clause(EqlParser.From_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.delete_clause())); + builder.append(QueryTokens.expression(ctx.FROM())); + builder.appendInline(visit(ctx.identification_variable_declaration())); - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); + if (!ctx.identificationVariableDeclarationOrCollectionMemberDeclaration().isEmpty()) { + builder.append(TOKEN_COMMA); } - return tokens; - } - - @Override - public List visitFrom_clause(EqlParser.From_clauseContext ctx) { - - List tokens = new ArrayList<>(); + builder.appendExpression(QueryTokenStream + .concat(ctx.identificationVariableDeclarationOrCollectionMemberDeclaration(), this::visit, TOKEN_COMMA)); - tokens.add(new JpaQueryParsingToken(ctx.FROM(), true)); - tokens.addAll(visit(ctx.identification_variable_declaration())); - - ctx.identificationVariableDeclarationOrCollectionMemberDeclaration() - .forEach(identificationVariableDeclarationOrCollectionMemberDeclarationContext -> { - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(identificationVariableDeclarationOrCollectionMemberDeclarationContext)); - }); - SPACE(tokens); - - return tokens; + return builder; } @Override - public List visitIdentificationVariableDeclarationOrCollectionMemberDeclaration( + public QueryTokenStream visitIdentificationVariableDeclarationOrCollectionMemberDeclaration( EqlParser.IdentificationVariableDeclarationOrCollectionMemberDeclarationContext ctx) { - if (ctx.identification_variable_declaration() != null) { - return visit(ctx.identification_variable_declaration()); - } else if (ctx.collection_member_declaration() != null) { - return visit(ctx.collection_member_declaration()); - } else if (ctx.subquery() != null) { - - List tokens = new ArrayList<>(); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - - return tokens; - } else { - return List.of(); - } - } - - @Override - public List visitIdentification_variable_declaration( - EqlParser.Identification_variable_declarationContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.range_variable_declaration())); - - ctx.join().forEach(joinContext -> { - tokens.addAll(visit(joinContext)); - }); - ctx.fetch_join().forEach(fetchJoinContext -> { - tokens.addAll(visit(fetchJoinContext)); - }); - - return tokens; - } - - @Override - public List visitRange_variable_declaration(EqlParser.Range_variable_declarationContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.entity_name() != null) { - tokens.addAll(visit(ctx.entity_name())); - } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); - } - - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); - } - - tokens.addAll(visit(ctx.identification_variable())); - - return tokens; - } - - @Override - public List visitJoin(EqlParser.JoinContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.join_spec())); - tokens.addAll(visit(ctx.join_association_path_expression())); - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); - } - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } - if (ctx.join_condition() != null) { - tokens.addAll(visit(ctx.join_condition())); - } + if (ctx.subquery() != null) { - return tokens; - } - - @Override - public List visitFetch_join(EqlParser.Fetch_joinContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.join_spec())); - tokens.add(new JpaQueryParsingToken(ctx.FETCH())); - tokens.addAll(visit(ctx.join_association_path_expression())); - - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); - } - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } - if (ctx.join_condition() != null) { - tokens.addAll(visit(ctx.join_condition())); - } + QueryRendererBuilder nested = QueryRenderer.builder(); + nested.append(TOKEN_OPEN_PAREN); + nested.appendInline(visit(ctx.subquery())); + nested.append(TOKEN_CLOSE_PAREN); - return tokens; - } + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.appendExpression(nested); - @Override - public List visitJoin_spec(EqlParser.Join_specContext ctx) { + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); + } - List tokens = new ArrayList<>(); + if (ctx.identification_variable() != null) { + builder.appendExpression(visit(ctx.identification_variable())); + } - if (ctx.LEFT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LEFT())); + return builder; } - if (ctx.OUTER() != null) { - tokens.add(new JpaQueryParsingToken(ctx.OUTER())); - } - if (ctx.INNER() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INNER())); - } - if (ctx.JOIN() != null) { - tokens.add(new JpaQueryParsingToken(ctx.JOIN())); - } - - return tokens; - } - - @Override - public List visitJoin_condition(EqlParser.Join_conditionContext ctx) { - - List tokens = new ArrayList<>(); - tokens.add(new JpaQueryParsingToken(ctx.ON())); - tokens.addAll(visit(ctx.conditional_expression())); - - return tokens; + return super.visitIdentificationVariableDeclarationOrCollectionMemberDeclaration(ctx); } @Override - public List visitJoin_association_path_expression( - EqlParser.Join_association_path_expressionContext ctx) { + public QueryTokenStream visitJoin_association_path_expression(EqlParser.Join_association_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.TREAT() == null) { if (ctx.join_collection_valued_path_expression() != null) { - tokens.addAll(visit(ctx.join_collection_valued_path_expression())); + builder.appendExpression(visit(ctx.join_collection_valued_path_expression())); } else if (ctx.join_single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.join_single_valued_path_expression())); + builder.appendExpression(visit(ctx.join_single_valued_path_expression())); } } else { + QueryRendererBuilder nested = QueryRenderer.builder(); + if (ctx.join_collection_valued_path_expression() != null) { - tokens.add(new JpaQueryParsingToken(ctx.TREAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.join_collection_valued_path_expression())); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.subtype())); - NOSPACE(tokens); - tokens.add(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) { - tokens.add(new JpaQueryParsingToken(ctx.TREAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.join_single_valued_path_expression())); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.subtype())); - NOSPACE(tokens); - tokens.add(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 tokens; + return builder; } @Override - public List visitJoin_collection_valued_path_expression( + public QueryTokenStream visitJoin_collection_valued_path_expression( EqlParser.Join_collection_valued_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + List items = new ArrayList<>(2 + ctx.single_valued_embeddable_object_field().size()); if (ctx.identification_variable() != null) { - - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); + items.add(ctx.identification_variable()); } - ctx.single_valued_embeddable_object_field().forEach(singleValuedEmbeddableObjectFieldContext -> { - tokens.addAll(visit(singleValuedEmbeddableObjectFieldContext)); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - }); - - tokens.addAll(visit(ctx.collection_valued_field())); + items.addAll(ctx.single_valued_embeddable_object_field()); + items.add(ctx.collection_valued_field()); - return tokens; + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } @Override - public List visitJoin_single_valued_path_expression( + public QueryTokenStream visitJoin_single_valued_path_expression( EqlParser.Join_single_valued_path_expressionContext ctx) { - List tokens = new ArrayList<>(); - + List items = new ArrayList<>(2 + ctx.single_valued_embeddable_object_field().size()); if (ctx.identification_variable() != null) { - - tokens.addAll(visit(ctx.identification_variable())); - tokens.add(TOKEN_DOT); + items.add(ctx.identification_variable()); } - ctx.single_valued_embeddable_object_field().forEach(singleValuedEmbeddableObjectFieldContext -> { - tokens.addAll(visit(singleValuedEmbeddableObjectFieldContext)); - tokens.add(TOKEN_DOT); - }); + items.addAll(ctx.single_valued_embeddable_object_field()); + items.add(ctx.single_valued_object_field()); - tokens.addAll(visit(ctx.single_valued_object_field())); - - return tokens; + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } @Override - public List visitCollection_member_declaration( - EqlParser.Collection_member_declarationContext ctx) { + public QueryTokenStream visitCollection_member_declaration(EqlParser.Collection_member_declarationContext ctx) { + + QueryRendererBuilder nested = QueryRenderer.builder(); - List tokens = new ArrayList<>(); + nested.append(QueryTokens.token(ctx.IN())); + nested.append(TOKEN_OPEN_PAREN); + nested.appendInline(visit(ctx.collection_valued_path_expression())); + nested.append(TOKEN_CLOSE_PAREN); - tokens.add(new JpaQueryParsingToken(ctx.IN(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.collection_valued_path_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.appendExpression(nested); if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); + builder.append(QueryTokens.expression(ctx.AS())); } - tokens.addAll(visit(ctx.identification_variable())); + if (ctx.identification_variable() != null) { + builder.appendExpression(visit(ctx.identification_variable())); + } - return tokens; + return builder; } @Override - public List visitQualified_identification_variable( + public QueryTokenStream visitQualified_identification_variable( EqlParser.Qualified_identification_variableContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.map_field_identification_variable() != null) { - tokens.addAll(visit(ctx.map_field_identification_variable())); + builder.append(visit(ctx.map_field_identification_variable())); } else if (ctx.identification_variable() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ENTRY())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.expression(ctx.ENTRY())); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.identification_variable())); + builder.append(TOKEN_CLOSE_PAREN); } - return tokens; + return builder; } @Override - public List visitMap_field_identification_variable( + public QueryTokenStream visitMap_field_identification_variable( EqlParser.Map_field_identification_variableContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.KEY() != null) { - tokens.add(new JpaQueryParsingToken(ctx.KEY(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.KEY())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.identification_variable())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.VALUE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.VALUE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.VALUE())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.identification_variable())); + builder.append(TOKEN_CLOSE_PAREN); } - return tokens; + return builder; } @Override - public List visitSingle_valued_path_expression( - EqlParser.Single_valued_path_expressionContext ctx) { + public QueryTokenStream visitSingle_valued_path_expression(EqlParser.Single_valued_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.qualified_identification_variable() != null) { - tokens.addAll(visit(ctx.qualified_identification_variable())); + builder.append(visit(ctx.qualified_identification_variable())); } else if (ctx.qualified_identification_variable() != null) { - tokens.add(new JpaQueryParsingToken(ctx.TREAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.qualified_identification_variable())); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.subtype())); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.TREAT())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.qualified_identification_variable())); + builder.append(QueryTokens.expression(ctx.AS())); + builder.appendInline(visit(ctx.subtype())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.state_field_path_expression() != null) { - tokens.addAll(visit(ctx.state_field_path_expression())); + builder.append(visit(ctx.state_field_path_expression())); } else if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); - } - - return tokens; - } - - @Override - public List visitGeneral_identification_variable( - EqlParser.General_identification_variableContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.map_field_identification_variable() != null) { - tokens.addAll(visit(ctx.map_field_identification_variable())); + builder.append(visit(ctx.single_valued_object_path_expression())); } - return tokens; + return builder; } @Override - public List visitGeneral_subpath(EqlParser.General_subpathContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitGeneral_subpath(EqlParser.General_subpathContext ctx) { if (ctx.simple_subpath() != null) { - tokens.addAll(visit(ctx.simple_subpath())); + return visit(ctx.simple_subpath()); } else if (ctx.treated_subpath() != null) { - tokens.addAll(visit(ctx.treated_subpath())); + List items = new ArrayList<>(1 + ctx.single_valued_object_field().size()); - ctx.single_valued_object_field().forEach(singleValuedObjectFieldContext -> { - tokens.add(TOKEN_DOT); - tokens.addAll(visit(singleValuedObjectFieldContext)); - }); + items.add(ctx.treated_subpath()); + items.addAll(ctx.single_valued_object_field()); + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } - return tokens; + return QueryTokenStream.empty(); } @Override - public List visitSimple_subpath(EqlParser.Simple_subpathContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitSimple_subpath(EqlParser.Simple_subpathContext ctx) { - tokens.addAll(visit(ctx.general_identification_variable())); - NOSPACE(tokens); + List items = new ArrayList<>(1 + ctx.single_valued_object_field().size()); - ctx.single_valued_object_field().forEach(singleValuedObjectFieldContext -> { - tokens.add(TOKEN_DOT); - tokens.addAll(visit(singleValuedObjectFieldContext)); - NOSPACE(tokens); - }); - SPACE(tokens); + items.add(ctx.general_identification_variable()); + items.addAll(ctx.single_valued_object_field()); - return tokens; + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } @Override - public List visitTreated_subpath(EqlParser.Treated_subpathContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.TREAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.general_subpath())); - SPACE(tokens); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.subtype())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + public QueryTokenStream visitTreated_subpath(EqlParser.Treated_subpathContext ctx) { - return tokens; - } - - @Override - public List visitState_field_path_expression(EqlParser.State_field_path_expressionContext ctx) { + QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - List tokens = new ArrayList<>(); + nested.appendExpression(visit(ctx.general_subpath())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.subtype())); - tokens.addAll(visit(ctx.general_subpath())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - tokens.addAll(visit(ctx.state_field())); + builder.append(QueryTokens.token(ctx.TREAT())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitState_valued_path_expression( - EqlParser.State_valued_path_expressionContext ctx) { + public QueryTokenStream visitState_field_path_expression(EqlParser.State_field_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.state_field_path_expression() != null) { - tokens.addAll(visit(ctx.state_field_path_expression())); - } else if (ctx.general_identification_variable() != null) { - tokens.addAll(visit(ctx.general_identification_variable())); - } + builder.appendInline(visit(ctx.general_subpath())); + builder.append(TOKEN_DOT); + builder.appendInline(visit(ctx.state_field())); - return tokens; + return builder; } @Override - public List visitSingle_valued_object_path_expression( + public QueryTokenStream visitSingle_valued_object_path_expression( EqlParser.Single_valued_object_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.general_subpath())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - tokens.addAll(visit(ctx.single_valued_object_field())); + builder.appendInline(visit(ctx.general_subpath())); + builder.append(TOKEN_DOT); + builder.appendInline(visit(ctx.single_valued_object_field())); - return tokens; + return builder; } @Override - public List visitCollection_valued_path_expression( + public QueryTokenStream visitCollection_valued_path_expression( EqlParser.Collection_valued_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.general_subpath())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - tokens.addAll(visit(ctx.collection_value_field())); + builder.appendInline(visit(ctx.general_subpath())); + builder.append(TOKEN_DOT); + builder.appendInline(visit(ctx.collection_value_field())); - return tokens; + return builder; } @Override - public List visitUpdate_clause(EqlParser.Update_clauseContext ctx) { + public QueryTokenStream visitUpdate_clause(EqlParser.Update_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.UPDATE())); - tokens.addAll(visit(ctx.entity_name())); + builder.append(QueryTokens.expression(ctx.UPDATE())); + builder.appendExpression(visit(ctx.entity_name())); if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); + builder.append(QueryTokens.expression(ctx.AS())); } + if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); + builder.appendExpression(visit(ctx.identification_variable())); } - tokens.add(new JpaQueryParsingToken(ctx.SET())); - - ctx.update_item().forEach(updateItemContext -> { - tokens.addAll(visit(updateItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); + builder.append(QueryTokens.expression(ctx.SET())); + builder.append(QueryTokenStream.concat(ctx.update_item(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitUpdate_item(EqlParser.Update_itemContext ctx) { + public QueryTokenStream visitUpdate_item(EqlParser.Update_itemContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); + + List items = new ArrayList<>(3 + ctx.single_valued_embeddable_object_field().size()); if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); + items.add(ctx.identification_variable()); } - ctx.single_valued_embeddable_object_field().forEach(singleValuedEmbeddableObjectFieldContext -> { - tokens.addAll(visit(singleValuedEmbeddableObjectFieldContext)); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - }); + items.addAll(ctx.single_valued_embeddable_object_field()); if (ctx.state_field() != null) { - tokens.addAll(visit(ctx.state_field())); + items.add(ctx.state_field()); } else if (ctx.single_valued_object_field() != null) { - tokens.addAll(visit(ctx.single_valued_object_field())); + items.add(ctx.single_valued_object_field()); } - tokens.add(TOKEN_EQUALS); - tokens.addAll(visit(ctx.new_value())); - - return tokens; - } + builder.appendInline(QueryTokenStream.concat(items, this::visit, TOKEN_DOT)); + builder.append(TOKEN_EQUALS); + builder.append(visit(ctx.new_value())); - @Override - public List visitNew_value(EqlParser.New_valueContext ctx) { - - if (ctx.scalar_expression() != null) { - return visit(ctx.scalar_expression()); - } else if (ctx.simple_entity_expression() != null) { - return visit(ctx.simple_entity_expression()); - } else if (ctx.NULL() != null) { - return List.of(new JpaQueryParsingToken(ctx.NULL())); - } else { - return List.of(); - } + return builder; } @Override - public List visitDelete_clause(EqlParser.Delete_clauseContext ctx) { + public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = prepareSelectClause(ctx); - tokens.add(new JpaQueryParsingToken(ctx.DELETE())); - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - tokens.addAll(visit(ctx.entity_name())); - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); - } - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } + builder.appendExpression(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } - @Override - public List visitSelect_clause(EqlParser.Select_clauseContext ctx) { + QueryRendererBuilder prepareSelectClause(EqlParser.Select_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.SELECT())); + builder.append(QueryTokens.expression(ctx.SELECT())); if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); - } - - ctx.select_item().forEach(selectItemContext -> { - tokens.addAll(visit(selectItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); - - return tokens; - } - - @Override - public List visitSelect_item(EqlParser.Select_itemContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.select_expression())); - SPACE(tokens); - - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); - } - - if (ctx.result_variable() != null) { - tokens.addAll(visit(ctx.result_variable())); + builder.append(QueryTokens.expression(ctx.DISTINCT())); } - return tokens; + return builder; } @Override - public List visitSelect_expression(EqlParser.Select_expressionContext ctx) { - - if (ctx.single_valued_path_expression() != null) { - return visit(ctx.single_valued_path_expression()); - } else if (ctx.scalar_expression() != null) { - return visit(ctx.scalar_expression()); - } else if (ctx.aggregate_expression() != null) { - return visit(ctx.aggregate_expression()); - } else if (ctx.identification_variable() != null) { + public QueryTokenStream visitSelect_expression(EqlParser.Select_expressionContext ctx) { - if (ctx.OBJECT() == null) { - return visit(ctx.identification_variable()); - } else { + if (ctx.identification_variable() != null && ctx.OBJECT() != null) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.OBJECT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.OBJECT())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.identification_variable())); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; - } - } else if (ctx.constructor_expression() != null) { - return visit(ctx.constructor_expression()); - } else { - return List.of(); + return builder; } - } - - @Override - public List visitConstructor_expression(EqlParser.Constructor_expressionContext ctx) { - - List tokens = new ArrayList<>(); - tokens.add(new JpaQueryParsingToken(ctx.NEW())); - tokens.addAll(visit(ctx.constructor_name())); - tokens.add(TOKEN_OPEN_PAREN); - - ctx.constructor_item().forEach(constructorItemContext -> { - tokens.addAll(visit(constructorItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; + return super.visitSelect_expression(ctx); } @Override - public List visitConstructor_item(EqlParser.Constructor_itemContext ctx) { + public QueryTokenStream visitConstructor_expression(EqlParser.Constructor_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_path_expression())); - } else if (ctx.scalar_expression() != null) { - tokens.addAll(visit(ctx.scalar_expression())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } + builder.append(QueryTokens.expression(ctx.NEW())); + builder.append(visit(ctx.constructor_name())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(QueryTokenStream.concat(ctx.constructor_item(), this::visit, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitAggregate_expression(EqlParser.Aggregate_expressionContext ctx) { + public QueryTokenStream visitAggregate_expression(EqlParser.Aggregate_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.AVG() != null || ctx.MAX() != null || ctx.MIN() != null || ctx.SUM() != null) { if (ctx.AVG() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AVG(), false)); + builder.append(QueryTokens.token(ctx.AVG())); } if (ctx.MAX() != null) { - tokens.add(new JpaQueryParsingToken(ctx.MAX(), false)); + builder.append(QueryTokens.token(ctx.MAX())); } if (ctx.MIN() != null) { - tokens.add(new JpaQueryParsingToken(ctx.MIN(), false)); + builder.append(QueryTokens.token(ctx.MIN())); } if (ctx.SUM() != null) { - tokens.add(new JpaQueryParsingToken(ctx.SUM(), false)); + builder.append(QueryTokens.token(ctx.SUM())); } - tokens.add(TOKEN_OPEN_PAREN); + builder.append(TOKEN_OPEN_PAREN); if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); + builder.append(QueryTokens.expression(ctx.DISTINCT())); } - tokens.addAll(visit(ctx.state_valued_path_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.appendInline(visit(ctx.simple_select_expression())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.COUNT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.COUNT(), false)); - tokens.add(TOKEN_OPEN_PAREN); + builder.append(QueryTokens.token(ctx.COUNT())); + builder.append(TOKEN_OPEN_PAREN); if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); - } - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); + builder.append(QueryTokens.expression(ctx.DISTINCT())); } - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.appendInline(visit(ctx.simple_select_expression())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); + builder.append(visit(ctx.function_invocation())); } - return tokens; + return builder; } @Override - public List visitWhere_clause(EqlParser.Where_clauseContext ctx) { + public QueryTokenStream visitGroupby_clause(EqlParser.Groupby_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.WHERE(), true)); - tokens.addAll(visit(ctx.conditional_expression())); + builder.append(QueryTokens.expression(ctx.GROUP())); + builder.append(QueryTokens.expression(ctx.BY())); + builder.appendExpression(QueryTokenStream.concat(ctx.groupby_item(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitGroupby_clause(EqlParser.Groupby_clauseContext ctx) { + public QueryTokenStream visitOrderby_clause(EqlParser.Orderby_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.GROUP())); - tokens.add(new JpaQueryParsingToken(ctx.BY())); - ctx.groupby_item().forEach(groupbyItemContext -> { - tokens.addAll(visit(groupbyItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); + builder.append(QueryTokens.expression(ctx.ORDER())); + builder.append(QueryTokens.expression(ctx.BY())); + builder.append(QueryTokenStream.concat(ctx.orderby_item(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitGroupby_item(EqlParser.Groupby_itemContext ctx) { + public QueryTokenStream visitSubquery_from_clause(EqlParser.Subquery_from_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_path_expression())); - } else if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.scalar_expression() != null) { - tokens.addAll(visit(ctx.scalar_expression())); - } + builder.append(QueryTokens.expression(ctx.FROM())); + builder.appendExpression( + QueryTokenStream.concat(ctx.subselect_identification_variable_declaration(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitHaving_clause(EqlParser.Having_clauseContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitConditional_primary(EqlParser.Conditional_primaryContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.HAVING())); - tokens.addAll(visit(ctx.conditional_expression())); + if (ctx.conditional_expression() != null) { + return QueryTokenStream.group(visit(ctx.conditional_expression())); + } - return tokens; + return super.visitConditional_primary(ctx); } @Override - public List visitOrderby_clause(EqlParser.Orderby_clauseContext ctx) { + public QueryTokenStream visitIn_expression(EqlParser.In_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.ORDER())); - tokens.add(new JpaQueryParsingToken(ctx.BY())); - - ctx.orderby_item().forEach(orderbyItemContext -> { - tokens.addAll(visit(orderbyItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - - return tokens; - } + if (ctx.string_expression() != null) { + builder.appendExpression(visit(ctx.string_expression())); + } - @Override - public List visitOrderby_item(EqlParser.Orderby_itemContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_field_path_expression() != null) { - tokens.addAll(visit(ctx.state_field_path_expression())); - } else if (ctx.general_identification_variable() != null) { - tokens.addAll(visit(ctx.general_identification_variable())); - } else if (ctx.result_variable() != null) { - tokens.addAll(visit(ctx.result_variable())); - } else if (ctx.string_expression() != null) { - tokens.addAll(visit(ctx.string_expression())); - } else if (ctx.scalar_expression() != null) { - tokens.addAll(visit(ctx.scalar_expression())); + if (ctx.type_discriminator() != null) { + builder.appendExpression(visit(ctx.type_discriminator())); } - if (ctx.ASC() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ASC())); + if (ctx.NOT() != null) { + builder.append(QueryTokens.expression(ctx.NOT())); } - if (ctx.DESC() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DESC())); + + if (ctx.IN() != null) { + builder.append(QueryTokens.expression(ctx.IN())); } - if (ctx.nullsPrecedence() != null) { - tokens.addAll(visit(ctx.nullsPrecedence())); + if (ctx.in_item() != null && !ctx.in_item().isEmpty()) { + builder.append(QueryTokenStream.group(QueryTokenStream.concat(ctx.in_item(), this::visit, TOKEN_COMMA))); + } else if (ctx.subquery() != null) { + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); + } else if (ctx.collection_valued_input_parameter() != null) { + builder.append(visit(ctx.collection_valued_input_parameter())); } - return tokens; + return builder; } @Override - public List visitNullsPrecedence(EqlParser.NullsPrecedenceContext ctx) { + public QueryTokenStream visitExists_expression(EqlParser.Exists_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(TOKEN_NULLS); - - if (ctx.FIRST() != null) { - tokens.add(TOKEN_FIRST); - } else if (ctx.LAST() != null) { - tokens.add(TOKEN_LAST); + if (ctx.NOT() != null) { + builder.append(QueryTokens.expression(ctx.NOT())); } - return tokens; + builder.append(QueryTokens.expression(ctx.EXISTS())); + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); + + return builder; } @Override - public List visitSubquery(EqlParser.SubqueryContext ctx) { + public QueryTokenStream visitAll_or_any_expression(EqlParser.All_or_any_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.simple_select_clause())); - tokens.addAll(visit(ctx.subquery_from_clause())); - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); - } - if (ctx.groupby_clause() != null) { - tokens.addAll(visit(ctx.groupby_clause())); - } - if (ctx.having_clause() != null) { - tokens.addAll(visit(ctx.having_clause())); + if (ctx.ALL() != null) { + builder.append(QueryTokens.expression(ctx.ALL())); + } else if (ctx.ANY() != null) { + builder.append(QueryTokens.expression(ctx.ANY())); + } else if (ctx.SOME() != null) { + builder.append(QueryTokens.expression(ctx.SOME())); } - return tokens; + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); + + return builder; } @Override - public List visitSubquery_from_clause(EqlParser.Subquery_from_clauseContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitArithmetic_factor(EqlParser.Arithmetic_factorContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - ctx.subselect_identification_variable_declaration().forEach(subselectIdentificationVariableDeclarationContext -> { - tokens.addAll(visit(subselectIdentificationVariableDeclarationContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); - - return tokens; - } + QueryRendererBuilder builder = QueryRenderer.builder(); - @Override - public List visitSubselect_identification_variable_declaration( - EqlParser.Subselect_identification_variable_declarationContext ctx) { - return super.visitSubselect_identification_variable_declaration(ctx); - } + if (ctx.op != null) { + builder.append(QueryTokens.token(ctx.op)); + } - @Override - public List visitDerived_path_expression(EqlParser.Derived_path_expressionContext ctx) { - return super.visitDerived_path_expression(ctx); - } + builder.append(visit(ctx.arithmetic_primary())); - @Override - public List visitGeneral_derived_path(EqlParser.General_derived_pathContext ctx) { - return super.visitGeneral_derived_path(ctx); + return builder; } @Override - public List visitSimple_derived_path(EqlParser.Simple_derived_pathContext ctx) { - return super.visitSimple_derived_path(ctx); - } + public QueryTokenStream visitArithmetic_primary(EqlParser.Arithmetic_primaryContext ctx) { - @Override - public List visitTreated_derived_path(EqlParser.Treated_derived_pathContext ctx) { - return super.visitTreated_derived_path(ctx); - } + if (ctx.arithmetic_expression() != null) { + return QueryTokenStream.group(visit(ctx.arithmetic_expression())); + } else if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); + } - @Override - public List visitDerived_collection_member_declaration( - EqlParser.Derived_collection_member_declarationContext ctx) { - return super.visitDerived_collection_member_declaration(ctx); + return super.visitArithmetic_primary(ctx); } @Override - public List visitSimple_select_clause(EqlParser.Simple_select_clauseContext ctx) { + public QueryTokenStream visitString_expression(EqlParser.String_expressionContext ctx) { - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.SELECT())); - if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - tokens.addAll(visit(ctx.simple_select_expression())); - return tokens; + return super.visitString_expression(ctx); } @Override - public List visitSimple_select_expression(EqlParser.Simple_select_expressionContext ctx) { + public QueryTokenStream visitDatetime_expression(EqlParser.Datetime_expressionContext ctx) { - List tokens = new ArrayList<>(); - - if (ctx.single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_path_expression())); - } else if (ctx.scalar_expression() != null) { - tokens.addAll(visit(ctx.scalar_expression())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return tokens; + return super.visitDatetime_expression(ctx); } @Override - public List visitScalar_expression(EqlParser.Scalar_expressionContext ctx) { + public QueryTokenStream visitBoolean_expression(EqlParser.Boolean_expressionContext ctx) { - List tokens = new ArrayList<>(); - - if (ctx.arithmetic_expression() != null) { - tokens.addAll(visit(ctx.arithmetic_expression())); - } else if (ctx.string_expression() != null) { - tokens.addAll(visit(ctx.string_expression())); - } else if (ctx.enum_expression() != null) { - tokens.addAll(visit(ctx.enum_expression())); - } else if (ctx.datetime_expression() != null) { - tokens.addAll(visit(ctx.datetime_expression())); - } else if (ctx.boolean_expression() != null) { - tokens.addAll(visit(ctx.boolean_expression())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.entity_type_expression() != null) { - tokens.addAll(visit(ctx.entity_type_expression())); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return tokens; + return super.visitBoolean_expression(ctx); } @Override - public List visitConditional_expression(EqlParser.Conditional_expressionContext ctx) { + public QueryTokenStream visitEnum_expression(EqlParser.Enum_expressionContext ctx) { - List tokens = new ArrayList<>(); - - if (ctx.conditional_expression() != null) { - tokens.addAll(visit(ctx.conditional_expression())); - tokens.add(new JpaQueryParsingToken(ctx.OR())); - tokens.addAll(visit(ctx.conditional_term())); - } else { - tokens.addAll(visit(ctx.conditional_term())); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return tokens; + return super.visitEnum_expression(ctx); } @Override - public List visitConditional_term(EqlParser.Conditional_termContext ctx) { + public QueryTokenStream visitType_discriminator(EqlParser.Type_discriminatorContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.conditional_term() != null) { - tokens.addAll(visit(ctx.conditional_term())); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.conditional_factor())); - } else { - tokens.addAll(visit(ctx.conditional_factor())); + if (ctx.general_identification_variable() != null) { + builder.append(visit(ctx.general_identification_variable())); + } else if (ctx.single_valued_object_path_expression() != null) { + builder.append(visit(ctx.single_valued_object_path_expression())); + } else if (ctx.input_parameter() != null) { + builder.append(visit(ctx.input_parameter())); } - return tokens; + return QueryTokenStream.ofFunction(ctx.TYPE(), builder); } @Override - public List visitConditional_factor(EqlParser.Conditional_factorContext ctx) { + public QueryTokenStream visitFunctions_returning_numerics(EqlParser.Functions_returning_numericsContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } + if (ctx.LENGTH() != null) { + return QueryTokenStream.ofFunction(ctx.LENGTH(), visit(ctx.string_expression(0))); + } else if (ctx.LOCATE() != null) { - EqlParser.Conditional_primaryContext conditionalPrimary = ctx.conditional_primary(); - List visitedConditionalPrimary = visit(conditionalPrimary); - tokens.addAll(visitedConditionalPrimary); + builder.appendInline(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.string_expression(1))); - return tokens; - } + if (ctx.arithmetic_expression() != null) { + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(0))); + } - @Override - public List visitConditional_primary(EqlParser.Conditional_primaryContext ctx) { + return QueryTokenStream.ofFunction(ctx.LOCATE(), builder); + } else if (ctx.ABS() != null) { + return QueryTokenStream.ofFunction(ctx.ABS(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.CEILING() != null) { + return QueryTokenStream.ofFunction(ctx.CEILING(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.EXP() != null) { + return QueryTokenStream.ofFunction(ctx.EXP(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.FLOOR() != null) { + return QueryTokenStream.ofFunction(ctx.FLOOR(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.LN() != null) { + return QueryTokenStream.ofFunction(ctx.LN(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.SIGN() != null) { + return QueryTokenStream.ofFunction(ctx.SIGN(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.SQRT() != null) { + return QueryTokenStream.ofFunction(ctx.SQRT(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.MOD() != null) { - List tokens = new ArrayList<>(); + builder.appendInline(visit(ctx.arithmetic_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(1))); - if (ctx.simple_cond_expression() != null) { - tokens.addAll(visit(ctx.simple_cond_expression())); - } else if (ctx.conditional_expression() != null) { + return QueryTokenStream.ofFunction(ctx.MOD(), builder); + } else if (ctx.POWER() != null) { - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.conditional_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } + builder.appendInline(visit(ctx.arithmetic_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(1))); - return tokens; - } + return QueryTokenStream.ofFunction(ctx.POWER(), builder); + } else if (ctx.ROUND() != null) { - @Override - public List visitSimple_cond_expression(EqlParser.Simple_cond_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.comparison_expression() != null) { - tokens.addAll(visit(ctx.comparison_expression())); - } else if (ctx.between_expression() != null) { - tokens.addAll(visit(ctx.between_expression())); - } else if (ctx.in_expression() != null) { - tokens.addAll(visit(ctx.in_expression())); - } else if (ctx.like_expression() != null) { - tokens.addAll(visit(ctx.like_expression())); - } else if (ctx.null_comparison_expression() != null) { - tokens.addAll(visit(ctx.null_comparison_expression())); - } else if (ctx.empty_collection_comparison_expression() != null) { - tokens.addAll(visit(ctx.empty_collection_comparison_expression())); - } else if (ctx.collection_member_expression() != null) { - tokens.addAll(visit(ctx.collection_member_expression())); - } else if (ctx.exists_expression() != null) { - tokens.addAll(visit(ctx.exists_expression())); + builder.appendInline(visit(ctx.arithmetic_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(1))); + + return QueryTokenStream.ofFunction(ctx.ROUND(), builder); + } else if (ctx.SIZE() != null) { + return QueryTokenStream.ofFunction(ctx.SIZE(), visit(ctx.collection_valued_path_expression())); + } else if (ctx.INDEX() != null) { + return QueryTokenStream.ofFunction(ctx.INDEX(), visit(ctx.identification_variable())); + } else if (ctx.extract_datetime_field() != null) { + builder.append(visit(ctx.extract_datetime_field())); } - return tokens; + return builder; } @Override - public List visitBetween_expression(EqlParser.Between_expressionContext ctx) { + public QueryTokenStream visitFunctions_returning_strings(EqlParser.Functions_returning_stringsContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.arithmetic_expression(0) != null) { + if (ctx.CONCAT() != null) { + return QueryTokenStream.ofFunction(ctx.CONCAT(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); + } else if (ctx.SUBSTRING() != null) { - tokens.addAll(visit(ctx.arithmetic_expression(0))); + builder.append(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(QueryTokenStream.concat(ctx.arithmetic_expression(), this::visit, TOKEN_COMMA)); + + return QueryTokenStream.ofFunction(ctx.SUBSTRING(), builder); + } else if (ctx.TRIM() != null) { - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); + if (ctx.trim_specification() != null) { + builder.appendExpression(visit(ctx.trim_specification())); + } + if (ctx.trim_character() != null) { + builder.appendExpression(visit(ctx.trim_character())); + } + if (ctx.FROM() != null) { + builder.append(QueryTokens.expression(ctx.FROM())); } - tokens.add(new JpaQueryParsingToken(ctx.BETWEEN())); - tokens.addAll(visit(ctx.arithmetic_expression(1))); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.arithmetic_expression(2))); + builder.append(visit(ctx.string_expression(0))); - } else if (ctx.string_expression(0) != null) { + return QueryTokenStream.ofFunction(ctx.TRIM(), builder); + } else if (ctx.LOWER() != null) { + return QueryTokenStream.ofFunction(ctx.LOWER(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); + } else if (ctx.UPPER() != null) { + return QueryTokenStream.ofFunction(ctx.UPPER(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); + } else if (ctx.LEFT() != null) { - tokens.addAll(visit(ctx.string_expression(0))); + builder.append(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.arithmetic_expression(0))); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } + return QueryTokenStream.ofFunction(ctx.LEFT(), builder); + } else if (ctx.RIGHT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.BETWEEN())); - tokens.addAll(visit(ctx.string_expression(1))); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.string_expression(2))); + builder.appendInline(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.arithmetic_expression(0))); - } else if (ctx.datetime_expression(0) != null) { - - tokens.addAll(visit(ctx.datetime_expression(0))); - - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - - tokens.add(new JpaQueryParsingToken(ctx.BETWEEN())); - tokens.addAll(visit(ctx.datetime_expression(1))); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.datetime_expression(2))); - } - - return tokens; - } - - @Override - public List visitIn_expression(EqlParser.In_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } - if (ctx.type_discriminator() != null) { - tokens.addAll(visit(ctx.type_discriminator())); - } - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - if (ctx.IN() != null) { - tokens.add(new JpaQueryParsingToken(ctx.IN())); - } - - if (ctx.in_item() != null && !ctx.in_item().isEmpty()) { - - tokens.add(TOKEN_OPEN_PAREN); - - ctx.in_item().forEach(inItemContext -> { - - tokens.addAll(visit(inItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.collection_valued_input_parameter() != null) { - tokens.addAll(visit(ctx.collection_valued_input_parameter())); - } - - return tokens; - } - - @Override - public List visitIn_item(EqlParser.In_itemContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.literal() != null) { - tokens.addAll(visit(ctx.literal())); - } else if (ctx.single_valued_input_parameter() != null) { - tokens.addAll(visit(ctx.single_valued_input_parameter())); - } - - return tokens; - } - - @Override - public List visitLike_expression(EqlParser.Like_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.string_expression())); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - tokens.add(new JpaQueryParsingToken(ctx.LIKE())); - tokens.addAll(visit(ctx.pattern_value())); - - if (ctx.ESCAPE() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.ESCAPE())); - tokens.addAll(visit(ctx.escape_character())); - } - - return tokens; - } - - @Override - public List visitNull_comparison_expression(EqlParser.Null_comparison_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_path_expression())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.nullif_expression() != null) { - tokens.addAll(visit(ctx.nullif_expression())); - } - - if (ctx.op != null) { - tokens.add(new JpaQueryParsingToken(ctx.op.getText())); - } else { - tokens.add(new JpaQueryParsingToken(ctx.IS())); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - } - tokens.add(new JpaQueryParsingToken(ctx.NULL())); - - return tokens; - } - - @Override - public List visitEmpty_collection_comparison_expression( - EqlParser.Empty_collection_comparison_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.collection_valued_path_expression())); - tokens.add(new JpaQueryParsingToken(ctx.IS())); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - tokens.add(new JpaQueryParsingToken(ctx.EMPTY())); - - return tokens; - } - - @Override - public List visitCollection_member_expression( - EqlParser.Collection_member_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.entity_or_value_expression())); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - tokens.add(new JpaQueryParsingToken(ctx.MEMBER())); - if (ctx.OF() != null) { - tokens.add(new JpaQueryParsingToken(ctx.OF())); - } - tokens.addAll(visit(ctx.collection_valued_path_expression())); - - return tokens; - } - - @Override - public List visitEntity_or_value_expression(EqlParser.Entity_or_value_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); - } else if (ctx.state_field_path_expression() != null) { - tokens.addAll(visit(ctx.state_field_path_expression())); - } else if (ctx.simple_entity_or_value_expression() != null) { - tokens.addAll(visit(ctx.simple_entity_or_value_expression())); - } - - return tokens; - } - - @Override - public List visitSimple_entity_or_value_expression( - EqlParser.Simple_entity_or_value_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.literal() != null) { - tokens.addAll(visit(ctx.literal())); - } - - return tokens; - } - - @Override - public List visitExists_expression(EqlParser.Exists_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - tokens.add(new JpaQueryParsingToken(ctx.EXISTS())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitAll_or_any_expression(EqlParser.All_or_any_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.ALL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ALL())); - } else if (ctx.ANY() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ANY())); - } else if (ctx.SOME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.SOME())); - } - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitStringComparison(EqlParser.StringComparisonContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.string_expression(0))); - tokens.addAll(visit(ctx.comparison_operator())); - - if (ctx.string_expression(1) != null) { - tokens.addAll(visit(ctx.string_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - - return tokens; - } - - @Override - public List visitBooleanComparison(EqlParser.BooleanComparisonContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.boolean_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - - if (ctx.boolean_expression(1) != null) { - tokens.addAll(visit(ctx.boolean_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - - return tokens; - } - - @Override - public List visitDirectBooleanCheck(EqlParser.DirectBooleanCheckContext ctx) { - return visit(ctx.boolean_expression()); - } - - @Override - public List visitEnumComparison(EqlParser.EnumComparisonContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.enum_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - - if (ctx.enum_expression(1) != null) { - tokens.addAll(visit(ctx.enum_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - - return tokens; - } - - @Override - public List visitDatetimeComparison(EqlParser.DatetimeComparisonContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.datetime_expression(0))); - tokens.addAll(visit(ctx.comparison_operator())); - - if (ctx.datetime_expression(1) != null) { - tokens.addAll(visit(ctx.datetime_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - - return tokens; - } - - @Override - public List visitEntityComparison(EqlParser.EntityComparisonContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.entity_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - - if (ctx.entity_expression(1) != null) { - tokens.addAll(visit(ctx.entity_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - - return tokens; - } - - @Override - public List visitArithmeticComparison(EqlParser.ArithmeticComparisonContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.arithmetic_expression(0))); - tokens.addAll(visit(ctx.comparison_operator())); - - if (ctx.arithmetic_expression(1) != null) { - tokens.addAll(visit(ctx.arithmetic_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - - return tokens; - } - - @Override - public List visitEntityTypeComparison(EqlParser.EntityTypeComparisonContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.entity_type_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - tokens.addAll(visit(ctx.entity_type_expression(1))); - - return tokens; - } - - @Override - public List visitRegexpComparison(EqlParser.RegexpComparisonContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.string_expression())); - tokens.add(new JpaQueryParsingToken(ctx.REGEXP())); - tokens.addAll(visit(ctx.string_literal())); - - return tokens; - } - - @Override - public List visitComparison_operator(EqlParser.Comparison_operatorContext ctx) { - return List.of(new JpaQueryParsingToken(ctx.op)); - } - - @Override - public List visitArithmetic_expression(EqlParser.Arithmetic_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.arithmetic_expression() != null) { - - tokens.addAll(visit(ctx.arithmetic_expression())); - tokens.add(new JpaQueryParsingToken(ctx.op)); - tokens.addAll(visit(ctx.arithmetic_term())); - - } else { - tokens.addAll(visit(ctx.arithmetic_term())); - } - - return tokens; - } - - @Override - public List visitArithmetic_term(EqlParser.Arithmetic_termContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.arithmetic_term() != null) { - - tokens.addAll(visit(ctx.arithmetic_term())); - NOSPACE(tokens); - tokens.add(new JpaQueryParsingToken(ctx.op, false)); - tokens.addAll(visit(ctx.arithmetic_factor())); - } else { - tokens.addAll(visit(ctx.arithmetic_factor())); - } - - return tokens; - } - - @Override - public List visitArithmetic_factor(EqlParser.Arithmetic_factorContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.op != null) { - tokens.add(new JpaQueryParsingToken(ctx.op, false)); - } - tokens.addAll(visit(ctx.arithmetic_primary())); - - return tokens; - } - - @Override - public List visitArithmetic_primary(EqlParser.Arithmetic_primaryContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.numeric_literal() != null) { - tokens.addAll(visit(ctx.numeric_literal())); - } else if (ctx.arithmetic_expression() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_numerics() != null) { - tokens.addAll(visit(ctx.functions_returning_numerics())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.cast_function() != null) { - tokens.addAll(visit(ctx.cast_function())); - } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitString_expression(EqlParser.String_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.string_literal() != null) { - tokens.addAll(visit(ctx.string_literal())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_strings() != null) { - tokens.addAll(visit(ctx.functions_returning_strings())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitDatetime_expression(EqlParser.Datetime_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_datetime() != null) { - tokens.addAll(visit(ctx.functions_returning_datetime())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); - } else if (ctx.date_time_timestamp_literal() != null) { - tokens.addAll(visit(ctx.date_time_timestamp_literal())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitBoolean_expression(EqlParser.Boolean_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.boolean_literal() != null) { - tokens.addAll(visit(ctx.boolean_literal())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitEnum_expression(EqlParser.Enum_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.enum_literal() != null) { - tokens.addAll(visit(ctx.enum_literal())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitEntity_expression(EqlParser.Entity_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); - } else if (ctx.simple_entity_expression() != null) { - tokens.addAll(visit(ctx.simple_entity_expression())); - } - - return tokens; - } - - @Override - public List visitSimple_entity_expression(EqlParser.Simple_entity_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } - - return tokens; - } - - @Override - public List visitEntity_type_expression(EqlParser.Entity_type_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.type_discriminator() != null) { - tokens.addAll(visit(ctx.type_discriminator())); - } else if (ctx.entity_type_literal() != null) { - tokens.addAll(visit(ctx.entity_type_literal())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); + return QueryTokenStream.ofFunction(ctx.RIGHT(), builder); + } else if (ctx.REPLACE() != null) { + return QueryTokenStream.ofFunction(ctx.REPLACE(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); } - return tokens; + return builder; } @Override - public List visitType_discriminator(EqlParser.Type_discriminatorContext ctx) { + public QueryTokenStream visitArithmetic_cast_function(EqlParser.Arithmetic_cast_functionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.TYPE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - - if (ctx.general_identification_variable() != null) { - tokens.addAll(visit(ctx.general_identification_variable())); - } else if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); + builder.appendExpression(visit(ctx.string_expression())); + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); } - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.f)); - return tokens; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override - public List visitFunctions_returning_numerics( - EqlParser.Functions_returning_numericsContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.LENGTH() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.LENGTH(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.LOCATE() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.LOCATE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.string_expression(1))); - NOSPACE(tokens); - if (ctx.arithmetic_expression() != null) { - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - } - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.ABS() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.ABS(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.CEILING() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.CEILING(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.EXP() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.EXP(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.FLOOR() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.FLOOR(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.LN() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.LN(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.SIGN() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.SIGN(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.SQRT() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.SQRT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.MOD() != null) { + public QueryTokenStream visitType_cast_function(EqlParser.Type_cast_functionContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.MOD(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.arithmetic_expression(1))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.POWER() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.POWER(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.arithmetic_expression(1))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.ROUND() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.ROUND(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.arithmetic_expression(1))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.SIZE() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.SIZE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.collection_valued_path_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.INDEX() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.INDEX(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.extract_datetime_field() != null) { - tokens.addAll(visit(ctx.extract_datetime_field())); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - return tokens; - } + builder.appendExpression(visit(ctx.scalar_expression())); - @Override - public List visitFunctions_returning_datetime( - EqlParser.Functions_returning_datetimeContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.CURRENT_DATE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT_DATE())); - } else if (ctx.CURRENT_TIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT_TIME())); - } else if (ctx.CURRENT_TIMESTAMP() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT_TIMESTAMP())); - } else if (ctx.LOCAL() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.LOCAL())); - - if (ctx.DATE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DATE())); - } else if (ctx.TIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.TIME())); - } else if (ctx.DATETIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DATETIME())); - } - } else if (ctx.extract_datetime_part() != null) { - tokens.addAll(visit(ctx.extract_datetime_part())); + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); } - return tokens; - } - - @Override - public List visitFunctions_returning_strings(EqlParser.Functions_returning_stringsContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.CONCAT() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.CONCAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - ctx.string_expression().forEach(stringExpressionContext -> { - tokens.addAll(visit(stringExpressionContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.SUBSTRING() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.SUBSTRING(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - ctx.arithmetic_expression().forEach(arithmeticExpressionContext -> { - tokens.addAll(visit(arithmeticExpressionContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.TRIM() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.TRIM(), false)); - tokens.add(TOKEN_OPEN_PAREN); - if (ctx.trim_specification() != null) { - tokens.addAll(visit(ctx.trim_specification())); - } - if (ctx.trim_character() != null) { - tokens.addAll(visit(ctx.trim_character())); - } - if (ctx.FROM() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - } - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.LOWER() != null) { + builder.appendInline(visit(ctx.identification_variable())); - tokens.add(new JpaQueryParsingToken(ctx.LOWER(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.UPPER() != null) { + if (!CollectionUtils.isEmpty(ctx.numeric_literal())) { - tokens.add(new JpaQueryParsingToken(ctx.UPPER(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(QueryTokenStream.concat(ctx.numeric_literal(), this::visit, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_PAREN); } - return tokens; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override - public List visitTrim_specification(EqlParser.Trim_specificationContext ctx) { + public QueryTokenStream visitString_cast_function(EqlParser.String_cast_functionContext ctx) { - if (ctx.LEADING() != null) { - return List.of(new JpaQueryParsingToken(ctx.LEADING())); - } else if (ctx.TRAILING() != null) { - return List.of(new JpaQueryParsingToken(ctx.TRAILING())); - } else { - return List.of(new JpaQueryParsingToken(ctx.BOTH())); - } - } + QueryRendererBuilder builder = QueryRenderer.builder(); - @Override - public List visitCast_function(EqlParser.Cast_functionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.CAST(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.single_valued_path_expression())); - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - - if (ctx.numeric_literal() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - ctx.numeric_literal().forEach(numericLiteralContext -> { - tokens.addAll(visit(numericLiteralContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.appendExpression(visit(ctx.scalar_expression())); + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); } - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.STRING())); - return tokens; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override - public List visitFunction_invocation(EqlParser.Function_invocationContext ctx) { + public QueryTokenStream visitFunction_invocation(EqlParser.Function_invocationContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.FUNCTION() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FUNCTION(), false)); + builder.append(QueryTokens.token(ctx.FUNCTION())); } else if (ctx.identification_variable() != null) { - - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - } - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.function_name())); - NOSPACE(tokens); - ctx.function_arg().forEach(functionArgContext -> { - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(functionArgContext)); - NOSPACE(tokens); - }); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitExtract_datetime_field(EqlParser.Extract_datetime_fieldContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.EXTRACT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.datetime_field())); - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - tokens.addAll(visit(ctx.datetime_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitDatetime_field(EqlParser.Datetime_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitExtract_datetime_part(EqlParser.Extract_datetime_partContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.EXTRACT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.datetime_part())); - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - tokens.addAll(visit(ctx.datetime_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitDatetime_part(EqlParser.Datetime_partContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitFunction_arg(EqlParser.Function_argContext ctx) { - - if (ctx.literal() != null) { - return visit(ctx.literal()); - } else if (ctx.state_valued_path_expression() != null) { - return visit(ctx.state_valued_path_expression()); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } else { - return visit(ctx.scalar_expression()); + builder.appendInline(visit(ctx.identification_variable())); } - } - - @Override - public List visitCase_expression(EqlParser.Case_expressionContext ctx) { - - if (ctx.general_case_expression() != null) { - return visit(ctx.general_case_expression()); - } else if (ctx.simple_case_expression() != null) { - return visit(ctx.simple_case_expression()); - } else if (ctx.coalesce_expression() != null) { - return visit(ctx.coalesce_expression()); - } else { - return visit(ctx.nullif_expression()); - } - } - - @Override - public List visitGeneral_case_expression(EqlParser.General_case_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.CASE())); - - ctx.when_clause().forEach(whenClauseContext -> { - tokens.addAll(visit(whenClauseContext)); - }); - - tokens.add(new JpaQueryParsingToken(ctx.ELSE())); - tokens.addAll(visit(ctx.scalar_expression())); - tokens.add(new JpaQueryParsingToken(ctx.END())); - - return tokens; - } - - @Override - public List visitWhen_clause(EqlParser.When_clauseContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.WHEN())); - tokens.addAll(visit(ctx.conditional_expression())); - tokens.add(new JpaQueryParsingToken(ctx.THEN())); - tokens.addAll(visit(ctx.scalar_expression())); - - return tokens; - } - - @Override - public List visitSimple_case_expression(EqlParser.Simple_case_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.CASE())); - tokens.addAll(visit(ctx.case_operand())); - - ctx.simple_when_clause().forEach(simpleWhenClauseContext -> { - tokens.addAll(visit(simpleWhenClauseContext)); - }); - - tokens.add(new JpaQueryParsingToken(ctx.ELSE())); - tokens.addAll(visit(ctx.scalar_expression())); - tokens.add(new JpaQueryParsingToken(ctx.END())); - - return tokens; - } - - @Override - public List visitCase_operand(EqlParser.Case_operandContext ctx) { - - if (ctx.state_valued_path_expression() != null) { - return visit(ctx.state_valued_path_expression()); - } else { - return visit(ctx.type_discriminator()); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.function_name())); + if (!ctx.function_arg().isEmpty()) { + builder.append(TOKEN_COMMA); } - } - - @Override - public List visitSimple_when_clause(EqlParser.Simple_when_clauseContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.WHEN())); - tokens.addAll(visit(ctx.scalar_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.THEN())); - tokens.addAll(visit(ctx.scalar_expression(1))); - - return tokens; - } - - @Override - public List visitCoalesce_expression(EqlParser.Coalesce_expressionContext ctx) { - - List tokens = new ArrayList<>(); - tokens.add(new JpaQueryParsingToken(ctx.COALESCE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - ctx.scalar_expression().forEach(scalarExpressionContext -> { - tokens.addAll(visit(scalarExpressionContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.appendInline(QueryTokenStream.concat(ctx.function_arg(), this::visit, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitNullif_expression(EqlParser.Nullif_expressionContext ctx) { + public QueryTokenStream visitExtract_datetime_field(EqlParser.Extract_datetime_fieldContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder nested = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.NULLIF(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.scalar_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.scalar_expression(1))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + nested.appendExpression(visit(ctx.datetime_field())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.datetime_expression())); - return tokens; + return QueryTokenStream.ofFunction(ctx.EXTRACT(), nested); } @Override - public List visitTrim_character(EqlParser.Trim_characterContext ctx) { + public QueryTokenStream visitExtract_datetime_part(EqlParser.Extract_datetime_partContext ctx) { - if (ctx.CHARACTER() != null) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); - } else if (ctx.character_valued_input_parameter() != null) { - return visit(ctx.character_valued_input_parameter()); - } else { - return List.of(); - } - } + QueryRendererBuilder nested = QueryRenderer.builder(); - @Override - public List visitIdentification_variable(EqlParser.Identification_variableContext ctx) { + nested.appendExpression(visit(ctx.datetime_part())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.datetime_expression())); - if (ctx.IDENTIFICATION_VARIABLE() != null) { - return List.of(new JpaQueryParsingToken(ctx.IDENTIFICATION_VARIABLE())); - } else if (ctx.f != null) { - return List.of(new JpaQueryParsingToken(ctx.f)); - } else { - return List.of(); - } + return QueryTokenStream.ofFunction(ctx.EXTRACT(), nested); } @Override - public List visitConstructor_name(EqlParser.Constructor_nameContext ctx) { + public QueryTokenStream visitCoalesce_expression(EqlParser.Coalesce_expressionContext ctx) { - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.entity_name())); - NOSPACE(tokens); - - return tokens; + return QueryTokenStream.ofFunction(ctx.COALESCE(), + QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); } @Override - public List visitLiteral(EqlParser.LiteralContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.STRINGLITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.STRINGLITERAL())); - } else if (ctx.INTLITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INTLITERAL())); - } else if (ctx.FLOATLITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FLOATLITERAL())); - } else if (ctx.LONGLITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LONGLITERAL())); - } else if (ctx.boolean_literal() != null) { - tokens.addAll(visit(ctx.boolean_literal())); - } else if (ctx.entity_type_literal() != null) { - tokens.addAll(visit(ctx.entity_type_literal())); - } + public QueryTokenStream visitNullif_expression(EqlParser.Nullif_expressionContext ctx) { - return tokens; + return QueryTokenStream.ofFunction(ctx.NULLIF(), + QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); } @Override - public List visitInput_parameter(EqlParser.Input_parameterContext ctx) { + public QueryTokenStream visitInput_parameter(EqlParser.Input_parameterContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.INTLITERAL() != null) { - tokens.add(TOKEN_QUESTION_MARK); - tokens.add(new JpaQueryParsingToken(ctx.INTLITERAL())); + builder.append(TOKEN_QUESTION_MARK); + builder.append(QueryTokens.token(ctx.INTLITERAL())); } else if (ctx.identification_variable() != null) { - tokens.add(TOKEN_COLON); - tokens.addAll(visit(ctx.identification_variable())); - } - - return tokens; - } - - @Override - public List visitPattern_value(EqlParser.Pattern_valueContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.string_expression())); - - return tokens; - } - - @Override - public List visitDate_time_timestamp_literal(EqlParser.Date_time_timestamp_literalContext ctx) { - - if (ctx.STRINGLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.STRINGLITERAL())); - } else if (ctx.DATELITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.DATELITERAL())); - } else if (ctx.TIMELITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.TIMELITERAL())); - } else if (ctx.TIMESTAMPLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.TIMESTAMPLITERAL())); - } else { - return List.of(); + builder.append(TOKEN_COLON); + builder.appendInline(visit(ctx.identification_variable())); } - } - @Override - public List visitEntity_type_literal(EqlParser.Entity_type_literalContext ctx) { - return visit(ctx.identification_variable()); + return builder; } @Override - public List visitEscape_character(EqlParser.Escape_characterContext ctx) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); + public QueryTokenStream visitEntity_name(EqlParser.Entity_nameContext ctx) { + return QueryTokenStream.concat(ctx.reserved_word(), this::visit, TOKEN_DOT); } @Override - public List visitNumeric_literal(EqlParser.Numeric_literalContext ctx) { - - if (ctx.INTLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.INTLITERAL())); - } else if (ctx.FLOATLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.FLOATLITERAL())); - } else if (ctx.LONGLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.LONGLITERAL())); - } else { - return List.of(); - } - } + public QueryTokenStream visitChildren(RuleNode node) { - @Override - public List visitBoolean_literal(EqlParser.Boolean_literalContext ctx) { + int childCount = node.getChildCount(); - if (ctx.TRUE() != null) { - return List.of(new JpaQueryParsingToken(ctx.TRUE())); - } else if (ctx.FALSE() != null) { - return List.of(new JpaQueryParsingToken(ctx.FALSE())); - } else { - return List.of(); + if (childCount == 1 && node.getChild(0) instanceof RuleContext t) { + return visit(t); } - } - - @Override - public List visitEnum_literal(EqlParser.Enum_literalContext ctx) { - return visit(ctx.state_field_path_expression()); - } - @Override - public List visitString_literal(EqlParser.String_literalContext ctx) { - - if (ctx.CHARACTER() != null) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); - } else if (ctx.STRINGLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.STRINGLITERAL())); - } else { - return List.of(); + if (childCount == 1 && node.getChild(0) instanceof TerminalNode t) { + return QueryTokens.token(t); } - } - - @Override - public List visitSingle_valued_embeddable_object_field( - EqlParser.Single_valued_embeddable_object_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitSubtype(EqlParser.SubtypeContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitCollection_valued_field(EqlParser.Collection_valued_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitSingle_valued_object_field(EqlParser.Single_valued_object_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitState_field(EqlParser.State_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitCollection_value_field(EqlParser.Collection_value_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitEntity_name(EqlParser.Entity_nameContext ctx) { - - List tokens = new ArrayList<>(); - - ctx.reserved_word().forEach(identificationVariableContext -> { - tokens.addAll(visitReserved_word(identificationVariableContext)); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - }); - CLIP(tokens); - SPACE(tokens); - - return tokens; - } - - @Override - public List visitResult_variable(EqlParser.Result_variableContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitSuperquery_identification_variable( - EqlParser.Superquery_identification_variableContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitCollection_valued_input_parameter( - EqlParser.Collection_valued_input_parameterContext ctx) { - return visit(ctx.input_parameter()); - } - - @Override - public List visitSingle_valued_input_parameter( - EqlParser.Single_valued_input_parameterContext ctx) { - return visit(ctx.input_parameter()); - } - - @Override - public List visitFunction_name(EqlParser.Function_nameContext ctx) { - return visit(ctx.string_literal()); - } - - @Override - public List visitCharacter_valued_input_parameter( - EqlParser.Character_valued_input_parameterContext ctx) { - if (ctx.CHARACTER() != null) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } else { - return List.of(); - } + return QueryTokenStream.concatExpressions(node, this::visit); } - @Override - public List visitReserved_word(EqlParser.Reserved_wordContext ctx) { - if (ctx.IDENTIFICATION_VARIABLE() != null) { - return List.of(new JpaQueryParsingToken(ctx.IDENTIFICATION_VARIABLE())); - } else if (ctx.f != null) { - return List.of(new JpaQueryParsingToken(ctx.f)); - } else { - return List.of(); - } - } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryTransformer.java deleted file mode 100644 index 890c5d39d8..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryTransformer.java +++ /dev/null @@ -1,246 +0,0 @@ -/* - * Copyright 2023-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 static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import org.springframework.data.domain.Sort; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; - -/** - * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed EQL query. - * - * @author Greg Turnquist - * @since 3.2 - */ -class EqlQueryTransformer extends EqlQueryRenderer { - - // TODO: Separate input from result parameters, encapsulation... - private final Sort sort; - private final boolean countQuery; - - private final @Nullable String countProjection; - - private @Nullable String primaryFromAlias = null; - - private List projection = Collections.emptyList(); - private boolean projectionProcessed; - - private boolean hasConstructorExpression = false; - - private JpaQueryTransformerSupport transformerSupport; - - EqlQueryTransformer() { - this(Sort.unsorted(), false, null); - } - - EqlQueryTransformer(Sort sort) { - this(sort, false, null); - } - - EqlQueryTransformer(boolean countQuery, @Nullable String countProjection) { - this(Sort.unsorted(), countQuery, countProjection); - } - - private EqlQueryTransformer(Sort sort, boolean countQuery, @Nullable String countProjection) { - - Assert.notNull(sort, "Sort must not be null"); - - this.sort = sort; - this.countQuery = countQuery; - this.countProjection = countProjection; - this.transformerSupport = new JpaQueryTransformerSupport(); - } - - @Nullable - public String getAlias() { - return this.primaryFromAlias; - } - - public List getProjection() { - return this.projection; - } - - public boolean hasConstructorExpression() { - return this.hasConstructorExpression; - } - - @Override - public List visitSelect_statement(EqlParser.Select_statementContext ctx) { - - List tokens = newArrayList(); - - tokens.addAll(visit(ctx.select_clause())); - tokens.addAll(visit(ctx.from_clause())); - - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); - } - - if (ctx.groupby_clause() != null) { - tokens.addAll(visit(ctx.groupby_clause())); - } - - if (ctx.having_clause() != null) { - tokens.addAll(visit(ctx.having_clause())); - } - - if (!countQuery) { - - if (ctx.orderby_clause() != null) { - tokens.addAll(visit(ctx.orderby_clause())); - } - - if (sort.isSorted()) { - - if (ctx.orderby_clause() != null) { - - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - } else { - - SPACE(tokens); - tokens.add(TOKEN_ORDER_BY); - } - - tokens.addAll(transformerSupport.generateOrderByArguments(primaryFromAlias, sort)); - } - } - - return tokens; - } - - @Override - public List visitSelect_clause(EqlParser.Select_clauseContext ctx) { - - List tokens = newArrayList(); - - tokens.add(new JpaQueryParsingToken(ctx.SELECT())); - - if (countQuery) { - tokens.add(TOKEN_COUNT_FUNC); - } - - if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); - } - - List selectItemTokens = newArrayList(); - - ctx.select_item().forEach(selectItemContext -> { - selectItemTokens.addAll(visit(selectItemContext)); - NOSPACE(selectItemTokens); - selectItemTokens.add(TOKEN_COMMA); - }); - CLIP(selectItemTokens); - SPACE(selectItemTokens); - - if (countQuery) { - - if (countProjection != null) { - tokens.add(new JpaQueryParsingToken(countProjection)); - } else { - - if (ctx.DISTINCT() != null) { - - List countSelection = QueryTransformers.filterCountSelection(selectItemTokens); - - if (countSelection.stream().anyMatch(jpqlToken -> jpqlToken.getToken().contains("new"))) { - // constructor - tokens.add(new JpaQueryParsingToken(() -> primaryFromAlias)); - } else { - // keep all the select items to distinct against - tokens.addAll(countSelection); - } - } else { - tokens.add(new JpaQueryParsingToken(() -> primaryFromAlias)); - } - } - - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else { - tokens.addAll(selectItemTokens); - } - - if (!projectionProcessed) { - projection = selectItemTokens; - projectionProcessed = true; - } - - return tokens; - } - - @Override - public List visitSelect_item(EqlParser.Select_itemContext ctx) { - - List tokens = super.visitSelect_item(ctx); - - if (ctx.result_variable() != null) { - transformerSupport.registerAlias(tokens.get(tokens.size() - 1).getToken()); - } - - return tokens; - } - - @Override - public List visitRange_variable_declaration(EqlParser.Range_variable_declarationContext ctx) { - - List tokens = newArrayList(); - - tokens.addAll(visit(ctx.entity_name())); - - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); - } - - tokens.addAll(visit(ctx.identification_variable())); - - if (primaryFromAlias == null) { - primaryFromAlias = tokens.get(tokens.size() - 1).getToken(); - } - - return tokens; - } - - @Override - public List visitJoin(EqlParser.JoinContext ctx) { - - List tokens = super.visitJoin(ctx); - - transformerSupport.registerAlias(tokens.get(tokens.size() - 1).getToken()); - - return tokens; - } - - @Override - public List visitConstructor_expression(EqlParser.Constructor_expressionContext ctx) { - - hasConstructorExpression = true; - - return super.visitConstructor_expression(ctx); - } - - private static ArrayList newArrayList() { - return new ArrayList<>(); - } - -} 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 new file mode 100644 index 0000000000..2cb03ae4df --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java @@ -0,0 +1,196 @@ +/* + * 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.springframework.data.jpa.repository.query.QueryTokens.*; + +import java.util.List; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.util.Assert; + +/** + * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed EQL query by applying + * {@link Sort}. + * + * @author Greg Turnquist + * @author Mark Paluch + * @author Christoph Strobl + * @since 3.2 + */ +@SuppressWarnings("ConstantValue") +class EqlSortedQueryTransformer extends EqlQueryRenderer { + + private final JpaQueryTransformerSupport transformerSupport = new JpaQueryTransformerSupport(); + private final Sort sort; + private final @Nullable String primaryFromAlias; + private final @Nullable DtoProjectionTransformerDelegate dtoDelegate; + + EqlSortedQueryTransformer(Sort sort, QueryInformation queryInformation, @Nullable ReturnedType returnedType) { + + Assert.notNull(sort, "Sort must not be null"); + Assert.notNull(queryInformation, "ParsedHqlQueryInformation must not be null"); + + this.sort = sort; + this.primaryFromAlias = queryInformation.getAlias(); + this.dtoDelegate = returnedType == null ? null : new DtoProjectionTransformerDelegate(returnedType); + } + + @Override + public QueryTokenStream visitSelectQuery(EqlParser.SelectQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.appendExpression(visit(ctx.select_clause())); + builder.appendExpression(visit(ctx.from_clause())); + + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); + } + + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } + + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } + + if (ctx.set_fuction() != null) { + builder.appendExpression(visit(ctx.set_fuction())); + } else { + doVisitOrderBy(builder, ctx.orderby_clause()); + } + + return builder; + } + + @Override + public QueryTokenStream visitFromQuery(EqlParser.FromQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.appendExpression(visit(ctx.from_clause())); + + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); + } + + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } + + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } + + if (ctx.set_fuction() != null) { + builder.appendExpression(visit(ctx.set_fuction())); + } else { + doVisitOrderBy(builder, ctx.orderby_clause()); + } + + return builder; + } + + @Override + public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { + + if (dtoDelegate == null) { + return super.visitSelect_clause(ctx); + } + + QueryRendererBuilder builder = prepareSelectClause(ctx); + + QueryTokenStream selectItems = QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA); + + if (dtoDelegate != null && dtoDelegate.canRewrite()) { + builder.append(dtoDelegate.getRewrittenSelectionList()); + } else { + builder.append(selectItems); + } + + return builder; + } + + @Override + public QueryTokenStream visitSelect_item(EqlParser.Select_itemContext ctx) { + + QueryTokenStream tokens = super.visitSelect_item(ctx); + + if (ctx.result_variable() != null && !tokens.isEmpty()) { + transformerSupport.registerAlias(ctx.result_variable().getText()); + } + + return tokens; + } + + @Override + public QueryTokenStream visitSelect_expression(EqlParser.Select_expressionContext ctx) { + + QueryTokenStream selectItem = super.visitSelect_expression(ctx); + + if (dtoDelegate != null && dtoDelegate.applyRewriting() && ctx.constructor_expression() == null) { + dtoDelegate.appendSelectItem(selectItem); + } + + return selectItem; + } + + @Override + public QueryTokenStream visitJoin(EqlParser.JoinContext ctx) { + + QueryTokenStream tokens = super.visitJoin(ctx); + + if (ctx.identification_variable() != null) { + transformerSupport.registerAlias(ctx.identification_variable().getText()); + } + + return tokens; + } + + private void doVisitOrderBy(QueryRendererBuilder builder, EqlParser.Orderby_clauseContext ctx) { + + if (ctx != null) { + QueryTokenStream existingOrder = visit(ctx); + if (sort.isSorted()) { + builder.appendInline(existingOrder); + } else { + builder.append(existingOrder); + } + } + + if (sort.isSorted()) { + + List sortBy = transformerSupport.orderBy(primaryFromAlias, sort); + + if (ctx != null) { + + QueryRendererBuilder extension = QueryRenderer.builder().append(TOKEN_COMMA).append(sortBy); + + builder.appendInline(extension); + } else { + builder.append(TOKEN_ORDER_BY); + builder.append(sortBy); + } + } + } + +} 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 99a1dd02ca..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 @@ -1,5 +1,5 @@ /* - * Copyright 2019-2024 the original author or authors. + * Copyright 2019-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,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 // @@ -69,11 +70,10 @@ public boolean equals(Object o) { return true; } - if (!(o instanceof EscapeCharacter)) { + if (!(o instanceof EscapeCharacter that)) { return false; } - EscapeCharacter that = (EscapeCharacter) o; return escapeCharacter == that.escapeCharacter; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionFactorySupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionFactorySupport.java new file mode 100644 index 0000000000..aa167eca54 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionFactorySupport.java @@ -0,0 +1,196 @@ +/* + * 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 jakarta.persistence.metamodel.Attribute.PersistentAttributeType.*; + +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToOne; +import jakarta.persistence.metamodel.Attribute; +import jakarta.persistence.metamodel.Bindable; +import jakarta.persistence.metamodel.ManagedType; +import jakarta.persistence.metamodel.PluralAttribute; +import jakarta.persistence.metamodel.SingularAttribute; + +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Member; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.data.mapping.PropertyPath; +import org.springframework.util.StringUtils; + +/** + * Support class to build expression factories for JPA query creation. + * + * @author Mark Paluch + * @since 4.0 + */ +class ExpressionFactorySupport { + + static final Map> ASSOCIATION_TYPES; + + static { + Map> persistentAttributeTypes = new HashMap<>(); + persistentAttributeTypes.put(ONE_TO_ONE, OneToOne.class); + persistentAttributeTypes.put(ONE_TO_MANY, null); + persistentAttributeTypes.put(MANY_TO_ONE, ManyToOne.class); + persistentAttributeTypes.put(MANY_TO_MANY, null); + persistentAttributeTypes.put(ELEMENT_COLLECTION, null); + + ASSOCIATION_TYPES = Collections.unmodifiableMap(persistentAttributeTypes); + } + + /** + * 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 resolver the {@link ModelPathResolver} to check for the model. + * @param property the property path + * @param isForSelection is the property navigated for the selection or ordering part of the query? if true, we need + * to generate an explicit outer join in order to prevent Hibernate to use an inner join instead. see + * https://hibernate.atlassian.net/browse/HHH-12999 + * @param hasRequiredOuterJoin has a parent already required an outer join? + * @param isLeafProperty is leaf property + * @param isRelationshipId whether property path refers to relationship id + * @return whether an outer join is to be used for integrating this attribute in a query. + */ + public boolean requiresOuterJoin(ModelPathResolver resolver, PropertyPath property, boolean isForSelection, + boolean hasRequiredOuterJoin, boolean isLeafProperty, boolean isRelationshipId) { + + Bindable propertyPathModel = resolver.resolve(property); + + if (!(propertyPathModel instanceof Attribute attribute)) { + return false; + } + + // not a persistent attribute type association (@OneToOne, @ManyToOne) + if (!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 = ONE_TO_ONE == attribute.getPersistentAttributeType() + && StringUtils.hasText(getAnnotationProperty(attribute, "mappedBy", "")); + + if ((isLeafProperty || isRelationshipId) && !isForSelection && !isCollection && !isInverseOptionalOneToOne + && !hasRequiredOuterJoin) { + return false; + } + + return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true); + } + + /** + * Checks if this property path is referencing to relationship id. + * + * @param resolver the {@link ModelPathResolver resolver}. + * @param property the property path. + * @return whether in a query is relationship id. + */ + public boolean isRelationshipId(ModelPathResolver resolver, PropertyPath property) { + + if (!property.hasNext()) { + return false; + } + + Bindable bindable = resolver.resolveNext(property); + return bindable instanceof SingularAttribute sa && sa.isId(); + } + + @SuppressWarnings("unchecked") + private static T getAnnotationProperty(Attribute attribute, String propertyName, T defaultValue) { + + Class associationAnnotation = ASSOCIATION_TYPES.get(attribute.getPersistentAttributeType()); + + if (associationAnnotation == null) { + return defaultValue; + } + + Member member = attribute.getJavaMember(); + + if (!(member instanceof AnnotatedElement annotatedMember)) { + return defaultValue; + } + + Annotation annotation = AnnotationUtils.getAnnotation(annotatedMember, associationAnnotation); + if (annotation == null) { + return defaultValue; + } + + T value = (T) AnnotationUtils.getValue(annotation, propertyName); + return value != null ? value : defaultValue; + } + + /** + * Required for EclipseLink: we try to avoid using from.get as EclipseLink produces an inner join regardless of which + * join operation is specified next + * + * @see https://bugs.eclipse.org/bugs/show_bug.cgi?id=413892 + * @param model + * @return + */ + static @Nullable ManagedType getManagedTypeForModel(@Nullable Object model) { + + if (model instanceof ManagedType managedType) { + return managedType; + } + + if (model instanceof PluralAttribute pa) { + return pa.getElementType() instanceof ManagedType managedType ? managedType : null; + } + + if (!(model instanceof SingularAttribute singularAttribute)) { + return null; + } + + return singularAttribute.getType() instanceof ManagedType managedType ? managedType : null; + } + + public interface ModelPathResolver { + + /** + * Resolve the {@link Bindable} for the given {@link PropertyPath}. + * + * @param propertyPath + * @return + */ + @Nullable + Bindable resolve(PropertyPath propertyPath); + + /** + * Resolve the next {@link Bindable} for the given {@link PropertyPath}. Requires the {@link PropertyPath#hasNext() + * to have a next item}. + * + * @param propertyPath + * @return + */ + @Nullable + Bindable resolveNext(PropertyPath propertyPath); + + } + +} 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 53291a0ea0..555c5d31d1 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -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()); @@ -93,7 +93,7 @@ public Object getValue(Parameter parameter) { protected Object potentiallyUnwrap(Object parameterValue) { return (parameterValue instanceof TypedParameterValue typedParameterValue) // - ? typedParameterValue.getValue() // + ? typedParameterValue.value() // : parameterValue; } } 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 new file mode 100644 index 0000000000..e9e283d88b --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateQueryInformation.java @@ -0,0 +1,46 @@ +/* + * 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; + +/** + * Hibernate-specific query details capturing common table expression details. + * + * @author Mark Paluch + * @author Oscar Fanchin + * @author Soomin Kim + * @since 3.5 + */ +class HibernateQueryInformation extends QueryInformation { + + private final boolean hasCte; + + private final boolean hasFromFunction; + + public HibernateQueryInformation(QueryInformationHolder introspection, boolean hasCte, boolean hasFromFunction) { + super(introspection); + this.hasCte = hasCte; + this.hasFromFunction = hasFromFunction; + } + + public boolean hasCte() { + return hasCte; + } + + public boolean hasFromFunction() { + return hasFromFunction; + } + +} 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 new file mode 100644 index 0000000000..6f9c3bb878 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlCountQueryTransformer.java @@ -0,0 +1,243 @@ +/* + * 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.springframework.data.jpa.repository.query.QueryTokens.*; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.jpa.repository.query.HqlParser.SelectClauseContext; +import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.data.jpa.repository.query.QueryTransformers.CountSelectionTokenStream; +import org.springframework.util.StringUtils; + +/** + * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed HQL query into a + * {@code COUNT(…)} query. + * + * @author Greg Turnquist + * @author Christoph Strobl + * @author Mark Paluch + * @author Oscar Fanchin + * @since 3.1 + */ +@SuppressWarnings("ConstantValue") +class HqlCountQueryTransformer extends HqlQueryRenderer { + + private final @Nullable String countProjection; + private final @Nullable String primaryFromAlias; + private final boolean containsCTE; + private final boolean containsFromFunction; + + HqlCountQueryTransformer(@Nullable String countProjection, HibernateQueryInformation queryInformation) { + this.countProjection = countProjection; + this.primaryFromAlias = queryInformation.getAlias(); + this.containsCTE = queryInformation.hasCte(); + this.containsFromFunction = queryInformation.hasFromFunction(); + } + + @Override + public QueryRendererBuilder visitOrderedQuery(HqlParser.OrderedQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + if (ctx.query() != null) { + builder.append(visit(ctx.query())); + } else if (ctx.queryExpression() != null) { + + QueryRendererBuilder nested = QueryRenderer.builder(); + nested.append(TOKEN_OPEN_PAREN); + nested.appendInline(visit(ctx.queryExpression())); + nested.append(TOKEN_CLOSE_PAREN); + + builder.appendExpression(nested); + } + + if (ctx.limitClause() != null) { + builder.appendExpression(visit(ctx.limitClause())); + } + + if (ctx.offsetClause() != null) { + builder.appendExpression(visit(ctx.offsetClause())); + } + + if (ctx.fetchClause() != null) { + builder.appendExpression(visit(ctx.fetchClause())); + } + + return builder; + } + + @Override + public QueryRendererBuilder visitFromQuery(HqlParser.FromQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + if (!isSubquery(ctx) && ctx.selectClause() == null) { + + QueryRendererBuilder countBuilder = QueryRenderer.builder(); + countBuilder.append(TOKEN_SELECT_COUNT); + + if (countProjection != null) { + countBuilder.append(QueryTokens.token(countProjection)); + } else { + if (primaryFromAlias == null) { + countBuilder.append(TOKEN_DOUBLE_UNDERSCORE); + } else { + countBuilder.append(QueryTokens.token(primaryFromAlias)); + } + } + + countBuilder.append(TOKEN_CLOSE_PAREN); + + builder.appendExpression(countBuilder); + } + + if (ctx.fromClause() != null) { + builder.appendExpression(visit(ctx.fromClause())); + if (primaryFromAlias == null) { + builder.append(TOKEN_AS); + builder.append(TOKEN_DOUBLE_UNDERSCORE); + } + } + + if (ctx.whereClause() != null) { + builder.appendExpression(visit(ctx.whereClause())); + } + + if (ctx.groupByClause() != null) { + builder.appendExpression(visit(ctx.groupByClause())); + } + + if (ctx.havingClause() != null) { + builder.appendExpression(visit(ctx.havingClause())); + } + + if (ctx.selectClause() != null) { + builder.appendExpression(visit(ctx.selectClause())); + } + + return builder; + } + + @Override + 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())); + + builder.appendExpression(visit(ctx.joinTarget())); + + if (ctx.joinRestriction() != null) { + builder.appendExpression(visit(ctx.joinRestriction())); + } + + return builder; + } + + @Override + public QueryTokenStream visitSelectClause(HqlParser.SelectClauseContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(QueryTokens.expression(ctx.SELECT())); + + if (isSubquery(ctx)) { + return visitSubQuerySelectClause(ctx, builder); + } + + builder.append(TOKEN_COUNT_FUNC); + boolean usesDistinct = ctx.DISTINCT() != null; + QueryRendererBuilder nested = QueryRenderer.builder(); + if (countProjection == null) { + if (usesDistinct) { + nested.append(QueryTokens.expression(ctx.DISTINCT())); + nested.append(getDistinctCountSelection(visit(ctx.selectionList()))); + } else { + + // with CTE primary alias fails with hibernate (WITH entities AS (…) SELECT count(c) FROM entities c) + if (containsCTE || containsFromFunction) { + nested.append(QueryTokens.token("*")); + } else { + + if (StringUtils.hasText(primaryFromAlias)) { + nested.append(QueryTokens.token(primaryFromAlias)); + } else { + nested.append(QueryTokens.token("*")); + } + } + } + } else { + if (usesDistinct) { + nested.append(QueryTokens.expression(ctx.DISTINCT())); + } + nested.append(QueryTokens.token(countProjection)); + } + + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); + return builder; + } + + @Override + public QueryTokenStream visitSelection(HqlParser.SelectionContext ctx) { + + if (isSubquery(ctx)) { + return super.visitSelection(ctx); + } + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(visit(ctx.selectExpression())); + + // do not append variables to skip AS field aliasing + + return builder; + } + + private QueryRendererBuilder visitSubQuerySelectClause(SelectClauseContext ctx, QueryRendererBuilder builder) { + + if (ctx.DISTINCT() != null) { + builder.append(QueryTokens.expression(ctx.DISTINCT())); + } + + builder.append(visit(ctx.selectionList())); + return builder; + } + + private QueryRendererBuilder getDistinctCountSelection(QueryTokenStream selectionListbuilder) { + + QueryRendererBuilder nested = new QueryRendererBuilder(); + CountSelectionTokenStream countSelection = CountSelectionTokenStream.create(selectionListbuilder); + + if (countSelection.requiresPrimaryAlias()) { + + if (primaryFromAlias != null) { + // constructor + nested.append(QueryTokens.token(primaryFromAlias)); + } else { + nested.append(countSelection.withoutConstructorExpression()); + } + } else { + // keep all the select items to distinct against + nested.append(selectionListbuilder); + } + return nested; + } + +} 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..b9905d242c --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitor.java @@ -0,0 +1,877 @@ +/* + * 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> { + + 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, 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, 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 left = visitRequired(ctx.expression(0)); + Expression 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 condition = visitRequired(ctx.expression(0)); + Expression lower = visitRequired(ctx.expression(1)); + Expression 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>) condition); + } else { + return cb.isNotEmpty((Expression>) condition); + } + } + + if (ctx.TRUE() != null) { + if (ctx.NOT() == null) { + return cb.isTrue((Expression) condition); + } else { + return cb.isFalse((Expression) condition); + } + } + + if (ctx.FALSE() != null) { + if (ctx.NOT() == null) { + return cb.isFalse((Expression) condition); + } else { + return cb.isTrue((Expression) condition); + } + } + + return null; + } + + @Override + public Expression visitStringPatternMatching(HqlParser.StringPatternMatchingContext ctx) { + + Expression condition = visitRequired(ctx.expression(0)); + Expression match = visitRequired(ctx.expression(1)); + Expression 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 if (ctx.ILIKE() != null && cb instanceof HibernateCriteriaBuilder) { + + 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); + } + } else { + throw new UnsupportedOperationException("Unsupported string pattern: " + ctx.getText()); + } + } + + @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 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 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 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 stringLiteral = charLiteralOf(ctx.trimCharacter().STRING_LITERAL()); + Expression 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 start = visitRequired(ctx.substringFunctionStartArgument().expression()); + + if (ctx.substringFunctionLengthArgument() != null) { + Expression 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 literalOf(TerminalNode node) { + + String text = node.getText(); + return cb.literal(unquoteStringLiteral(text)); + } + + private Expression 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 left = visitRequired(ctx.expression(0)); + Expression 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 left = visitRequired(ctx.expression(0)); + Expression 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 left = visitRequired(ctx.expression(0)); + Expression 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 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 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 Expression visitRequired(ParseTree ctx) { + + Expression expression = visit(ctx); + + if (expression == null) { + throw new UnsupportedOperationException("No result for expression: " + ctx.getText()); + } + + return (Expression) 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 new file mode 100644 index 0000000000..776746f79f --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryIntrospector.java @@ -0,0 +1,136 @@ +/* + * 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 org.springframework.data.jpa.repository.query.HqlParser.VariableContext; + +/** + * {@link ParsedQueryIntrospector} for HQL queries. + * + * @author Mark Paluch + * @author Oscar Fanchin + * @author Soomin Kim + */ +@SuppressWarnings({ "UnreachableCode" }) +class HqlQueryIntrospector extends HqlBaseVisitor implements ParsedQueryIntrospector { + + private final HqlQueryRenderer renderer = new HqlQueryRenderer(); + private final QueryInformationHolder introspection = new QueryInformationHolder(); + + private boolean hasCte = false; + private boolean hasFromFunction = false; + + @Override + public HibernateQueryInformation getParsedQueryInformation() { + return new HibernateQueryInformation(introspection, hasCte, hasFromFunction); + } + + @Override + public Void visitSelectStatement(HqlParser.SelectStatementContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.SELECT); + return super.visitSelectStatement(ctx); + } + + @Override + public Void visitFromQuery(HqlParser.FromQueryContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.SELECT); + return super.visitFromQuery(ctx); + } + + @Override + public Void visitInsertStatement(HqlParser.InsertStatementContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.INSERT); + return super.visitInsertStatement(ctx); + } + + @Override + public Void visitUpdateStatement(HqlParser.UpdateStatementContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.UPDATE); + return super.visitUpdateStatement(ctx); + } + + @Override + public Void visitDeleteStatement(HqlParser.DeleteStatementContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.DELETE); + return super.visitDeleteStatement(ctx); + } + + @Override + public Void visitSelectClause(HqlParser.SelectClauseContext ctx) { + + introspection.captureProjection(ctx.selectionList().selection(), renderer::visitSelection); + + return super.visitSelectClause(ctx); + } + + @Override + public Void visitCte(HqlParser.CteContext ctx) { + this.hasCte = true; + return super.visitCte(ctx); + } + + @Override + public Void visitRootEntity(HqlParser.RootEntityContext ctx) { + + if (ctx.variable() != null && !HqlQueryRenderer.isSubquery(ctx) + && !HqlQueryRenderer.isSetQuery(ctx)) { + capturePrimaryAlias(ctx.variable()); + } + + return super.visitRootEntity(ctx); + } + + @Override + public Void visitRootSubquery(HqlParser.RootSubqueryContext ctx) { + + if (ctx.variable() != null && !HqlQueryRenderer.isSubquery(ctx) + && !HqlQueryRenderer.isSetQuery(ctx)) { + capturePrimaryAlias(ctx.variable()); + } + + return super.visitRootSubquery(ctx); + } + + @Override + public Void visitRootFunction(HqlParser.RootFunctionContext ctx) { + + if (ctx.variable() != null && !HqlQueryRenderer.isSubquery(ctx) + && !HqlQueryRenderer.isSetQuery(ctx)) { + capturePrimaryAlias(ctx.variable()); + this.hasFromFunction = true; + } + + return super.visitRootFunction(ctx); + } + + @Override + public Void visitInstantiation(HqlParser.InstantiationContext ctx) { + + introspection.constructorExpressionPresent(); + return super.visitInstantiation(ctx); + } + + private void capturePrimaryAlias(VariableContext ctx) { + introspection + .capturePrimaryAlias((ctx.nakedIdentifier() != null ? ctx.nakedIdentifier() : ctx.identifier()).getText()); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryParser.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryParser.java deleted file mode 100644 index 9842c2b6b8..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryParser.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2022-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; - -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.ParserRuleContext; -import org.springframework.data.domain.Sort; -import org.springframework.lang.Nullable; - -/** - * Implements the {@code HQL} parsing operations of a {@link JpaQueryParserSupport} using the ANTLR-generated - * {@link HqlParser} and {@link HqlQueryTransformer}. - * - * @author Greg Turnquist - * @author Mark Paluch - * @since 3.1 - */ -class HqlQueryParser extends JpaQueryParserSupport { - - HqlQueryParser(String query) { - super(query); - } - - /** - * Convenience method to parse an HQL query. Will throw a {@link BadJpqlGrammarException} if the query is invalid. - * - * @param query - * @return a parsed query, ready for postprocessing - */ - public static ParserRuleContext parseQuery(String query) { - - HqlLexer lexer = new HqlLexer(CharStreams.fromString(query)); - HqlParser parser = new HqlParser(new CommonTokenStream(lexer)); - - configureParser(query, lexer, parser); - - return parser.start(); - } - - /** - * Parse the query using {@link #parseQuery(String)}. - * - * @return a parsed query - */ - @Override - protected ParserRuleContext parse(String query) { - return parseQuery(query); - } - - /** - * Use the {@link HqlQueryTransformer} to transform the original query into a query with the {@link Sort} applied. - * - * @param parsedQuery - * @param sort can be {@literal null} - * @return list of {@link JpaQueryParsingToken}s - */ - @Override - protected List applySort(ParserRuleContext parsedQuery, Sort sort) { - return new HqlQueryTransformer(sort).visit(parsedQuery); - } - - /** - * Use the {@link HqlQueryTransformer} to transform the original query into a count query. - * - * @param parsedQuery - * @param countProjection - * @return list of {@link JpaQueryParsingToken}s - */ - @Override - protected List doCreateCountQuery(ParserRuleContext parsedQuery, - @Nullable String countProjection) { - return new HqlQueryTransformer(true, countProjection).visit(parsedQuery); - } - - /** - * Run the parsed query through {@link HqlQueryTransformer} to find the primary FROM clause's alias. - * - * @param parsedQuery - * @return can be {@literal null} - */ - @Override - protected String doFindAlias(ParserRuleContext parsedQuery) { - - HqlQueryTransformer transformVisitor = new HqlQueryTransformer(); - transformVisitor.visit(parsedQuery); - return transformVisitor.getAlias(); - } - - /** - * Use {@link HqlQueryTransformer} to find the projection of the query. - * - * @param parsedQuery - * @return - */ - @Override - protected List doFindProjection(ParserRuleContext parsedQuery) { - - HqlQueryTransformer transformVisitor = new HqlQueryTransformer(); - transformVisitor.visit(parsedQuery); - return transformVisitor.getProjection(); - } - - /** - * Use {@link HqlQueryTransformer} to detect if the query uses a {@code new com.example.Dto()} DTO constructor in the - * primary select clause. - * - * @param parsedQuery - * @return Guaranteed to be {@literal true} or {@literal false}. - */ - @Override - protected boolean doCheckForConstructor(ParserRuleContext parsedQuery) { - - HqlQueryTransformer transformVisitor = new HqlQueryTransformer(); - transformVisitor.visit(parsedQuery); - return transformVisitor.hasConstructorExpression(); - } -} 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 5b8c990fe9..72b3296778 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -15,2553 +15,2108 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; +import static org.springframework.data.jpa.repository.query.QueryTokens.*; import java.util.ArrayList; import java.util.List; +import org.antlr.v4.runtime.ParserRuleContext; +import org.antlr.v4.runtime.RuleContext; +import org.antlr.v4.runtime.tree.ParseTree; +import org.antlr.v4.runtime.tree.RuleNode; +import org.antlr.v4.runtime.tree.TerminalNode; + +import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.util.CollectionUtils; + /** * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that renders an HQL query without making any changes. * * @author Greg Turnquist * @author Christoph Strobl + * @author Oscar Fanchin + * @author Mark Paluch + * @author TaeHyun Kang * @since 3.1 */ -@SuppressWarnings({ "ConstantConditions", "DuplicatedCode" }) -class HqlQueryRenderer extends HqlBaseVisitor> { +@SuppressWarnings({ "ConstantConditions", "DuplicatedCode", "UnreachableCode" }) +class HqlQueryRenderer extends HqlBaseVisitor { - @Override - public List visitStart(HqlParser.StartContext ctx) { - return visit(ctx.ql_statement()); - } + /** + * Is this AST tree a {@literal subquery}? + * + * @return {@literal true} is the query is a subquery; {@literal false} otherwise. + */ + static boolean isSubquery(ParserRuleContext ctx) { - @Override - public List visitQl_statement(HqlParser.Ql_statementContext ctx) { + while (ctx != null) { - if (ctx.selectStatement() != null) { - return visit(ctx.selectStatement()); - } else if (ctx.updateStatement() != null) { - return visit(ctx.updateStatement()); - } else if (ctx.deleteStatement() != null) { - return visit(ctx.deleteStatement()); - } else if (ctx.insertStatement() != null) { - return visit(ctx.insertStatement()); - } else { - return List.of(); - } - } + if (ctx instanceof HqlParser.SubqueryContext || ctx instanceof HqlParser.CteContext) { + return true; + } - @Override - public List visitSelectStatement(HqlParser.SelectStatementContext ctx) { - return visit(ctx.queryExpression()); - } + if (ctx instanceof HqlParser.SelectStatementContext || + ctx instanceof HqlParser.InsertStatementContext || + ctx instanceof HqlParser.DeleteStatementContext || + ctx instanceof HqlParser.UpdateStatementContext) { + return false; + } - @Override - public List visitQueryExpression(HqlParser.QueryExpressionContext ctx) { + ctx = ctx.getParent(); + } - List tokens = new ArrayList<>(); + return false; + } - if (ctx.withClause() != null) { - tokens.addAll(visit(ctx.withClause())); - } + /** + * Is this AST tree a {@literal set} query that has been added through {@literal UNION|INTERSECT|EXCEPT}? + * + * @return {@literal true} is the query is a set query; {@literal false} otherwise. + */ + static boolean isSetQuery(ParserRuleContext ctx) { - tokens.addAll(visit(ctx.orderedQuery(0))); + while (ctx != null) { - for (int i = 1; i < ctx.orderedQuery().size(); i++) { + if (ctx instanceof HqlParser.OrderedQueryContext + && ctx.getParent() instanceof HqlParser.QueryExpressionContext qec) { + if (qec.orderedQuery().indexOf(ctx) != 0) { + return true; + } + } - tokens.addAll(visit(ctx.setOperator(i - 1))); - tokens.addAll(visit(ctx.orderedQuery(i))); + ctx = ctx.getParent(); } - return tokens; + return false; } @Override - public List visitWithClause(HqlParser.WithClauseContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(TOKEN_WITH); + public QueryTokenStream visitStart(HqlParser.StartContext ctx) { + return visit(ctx.ql_statement()); + } - ctx.cte().forEach(cteContext -> { + @Override + public QueryTokenStream visitWithClause(HqlParser.WithClauseContext ctx) { - tokens.addAll(visit(cteContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(QueryTokens.expression(ctx.WITH())); + builder.append(QueryTokenStream.concatExpressions(ctx.cte(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitCte(HqlParser.CteContext ctx) { + public QueryTokenStream visitCte(HqlParser.CteContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.identifier())); - tokens.add(TOKEN_AS); - NOSPACE(tokens); + builder.appendExpression(visit(ctx.identifier())); + builder.append(TOKEN_AS); if (ctx.NOT() != null) { - tokens.add(TOKEN_NOT); + builder.append(QueryTokens.expression(ctx.NOT())); } + if (ctx.MATERIALIZED() != null) { - tokens.add(TOKEN_MATERIALIZED); + builder.append(TOKEN_MATERIALIZED); } - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.queryExpression())); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.queryExpression())); + builder.append(TOKEN_CLOSE_PAREN); if (ctx.searchClause() != null) { - tokens.addAll(visit(ctx.searchClause())); + builder.appendExpression(visit(ctx.searchClause())); } + if (ctx.cycleClause() != null) { - tokens.addAll(visit(ctx.cycleClause())); + builder.appendExpression(visit(ctx.cycleClause())); } - return tokens; + return builder; } @Override - public List visitSearchClause(HqlParser.SearchClauseContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.SEARCH().getText())); - - if (ctx.BREADTH() != null) { - tokens.add(new JpaQueryParsingToken(ctx.BREADTH().getText())); - } else if (ctx.DEPTH() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DEPTH().getText())); - } - - tokens.add(new JpaQueryParsingToken(ctx.FIRST().getText())); - tokens.add(new JpaQueryParsingToken(ctx.BY().getText())); - tokens.addAll(visit(ctx.searchSpecifications())); - tokens.add(new JpaQueryParsingToken(ctx.SET().getText())); - tokens.addAll(visit(ctx.identifier())); - - return tokens; + public QueryTokenStream visitCteAttributes(HqlParser.CteAttributesContext ctx) { + return QueryTokenStream.concat(ctx.identifier(), this::visit, TOKEN_COMMA); } @Override - public List visitSearchSpecifications(HqlParser.SearchSpecificationsContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitSearchSpecifications(HqlParser.SearchSpecificationsContext ctx) { + return QueryTokenStream.concat(ctx.searchSpecification(), this::visit, TOKEN_COMMA); + } - ctx.searchSpecification().forEach(searchSpecificationContext -> { + @Override + public QueryTokenStream visitOrderedQuery(HqlParser.OrderedQueryContext ctx) { - tokens.addAll(visit(searchSpecificationContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + QueryRendererBuilder builder = QueryRenderer.builder(); - return tokens; - } + if (ctx.query() != null) { + builder.append(visit(ctx.query())); + } else if (ctx.queryExpression() != null) { - @Override - public List visitSearchSpecification(HqlParser.SearchSpecificationContext ctx) { + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.queryExpression())); + builder.append(TOKEN_CLOSE_PAREN); + } - List tokens = new ArrayList<>(); + if (ctx.queryOrder() != null) { + builder.append(visit(ctx.queryOrder())); + } - tokens.addAll(visit(ctx.identifier())); + if (ctx.limitClause() != null) { + builder.appendExpression(visit(ctx.limitClause())); + } - if (ctx.sortDirection() != null) { - tokens.addAll(visit(ctx.sortDirection())); + if (ctx.offsetClause() != null) { + builder.appendExpression(visit(ctx.offsetClause())); } - if (ctx.nullsPrecedence() != null) { - tokens.addAll(visit(ctx.nullsPrecedence())); + if (ctx.fetchClause() != null) { + builder.appendExpression(visit(ctx.fetchClause())); } - return tokens; + return builder; } @Override - public List visitCycleClause(HqlParser.CycleClauseContext ctx) { + public QueryTokenStream visitFromClause(HqlParser.FromClauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.CYCLE().getText())); - tokens.addAll(visit(ctx.cteAttributes())); - tokens.add(new JpaQueryParsingToken(ctx.SET().getText())); - tokens.addAll(visit(ctx.identifier(0))); + builder.append(QueryTokens.expression(ctx.FROM())); + builder.appendExpression(QueryTokenStream.concat(ctx.entityWithJoins(), this::visit, TOKEN_COMMA)); - if (ctx.TO() != null) { + return builder; + } - tokens.add(new JpaQueryParsingToken(ctx.TO().getText())); - tokens.addAll(visit(ctx.literal(0))); - tokens.add(new JpaQueryParsingToken(ctx.DEFAULT().getText())); - tokens.addAll(visit(ctx.literal(1))); - } + @Override + public QueryTokenStream visitEntityWithJoins(HqlParser.EntityWithJoinsContext ctx) { - if (ctx.USING() != null) { + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.USING().getText())); - tokens.addAll(visit(ctx.identifier(1))); - } + builder.appendInline(visit(ctx.fromRoot())); + builder.appendInline(QueryTokenStream.concat(ctx.joinSpecifier(), this::visit, EMPTY_TOKEN)); - return tokens; + return builder; } @Override - public List visitCteAttributes(HqlParser.CteAttributesContext ctx) { + public QueryTokenStream visitRootSubquery(HqlParser.RootSubqueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); - List tokens = new ArrayList<>(); + if (ctx.LATERAL() != null) { + builder.append(QueryTokens.expression(ctx.LATERAL())); + } - ctx.identifier().forEach(identifierContext -> { + builder.appendExpression(QueryTokenStream.group(visit(ctx.subquery()))); - tokens.addAll(visit(identifierContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + if (ctx.variable() != null) { + builder.appendExpression(visit(ctx.variable())); + } - return tokens; + return builder; } @Override - public List visitOrderedQuery(HqlParser.OrderedQueryContext ctx) { + public QueryTokenStream visitSimpleSetReturningFunction(HqlParser.SimpleSetReturningFunctionContext ctx) { - List tokens = new ArrayList<>(); - - if (ctx.query() != null) { - tokens.addAll(visit(ctx.query())); - } else if (ctx.queryExpression() != null) { + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.queryExpression())); - tokens.add(TOKEN_CLOSE_PAREN); - } + builder.append(visit(ctx.identifier())); - if (ctx.queryOrder() != null) { - tokens.addAll(visit(ctx.queryOrder())); + builder.append(TOKEN_OPEN_PAREN); + if (ctx.genericFunctionArguments() != null) { + builder.append(visit(ctx.genericFunctionArguments())); } + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitSelectQuery(HqlParser.SelectQueryContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitJoin(HqlParser.JoinContext ctx) { - if (ctx.selectClause() != null) { - tokens.addAll(visit(ctx.selectClause())); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.fromClause() != null) { - tokens.addAll(visit(ctx.fromClause())); - } + builder.append(TOKEN_SPACE); + builder.append(visit(ctx.joinType())); + builder.append(QueryTokens.expression(ctx.JOIN())); - if (ctx.whereClause() != null) { - tokens.addAll(visit(ctx.whereClause())); + if (ctx.FETCH() != null) { + builder.append(QueryTokens.expression(ctx.FETCH())); } - if (ctx.groupByClause() != null) { - tokens.addAll(visit(ctx.groupByClause())); - } + builder.append(visit(ctx.joinTarget())); - if (ctx.havingClause() != null) { - tokens.addAll(visit(ctx.havingClause())); + if (ctx.joinRestriction() != null) { + builder.appendExpression(visit(ctx.joinRestriction())); } - return tokens; + return builder; } @Override - public List visitFromQuery(HqlParser.FromQueryContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitJoinSubquery(HqlParser.JoinSubqueryContext ctx) { - tokens.addAll(visit(ctx.fromClause())); - - if (ctx.whereClause() != null) { - tokens.addAll(visit(ctx.whereClause())); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.groupByClause() != null) { - tokens.addAll(visit(ctx.groupByClause())); + if (ctx.LATERAL() != null) { + builder.append(QueryTokens.expression(ctx.LATERAL())); } - if (ctx.havingClause() != null) { - tokens.addAll(visit(ctx.havingClause())); - } + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); - if (ctx.selectClause() != null) { - tokens.addAll(visit(ctx.selectClause())); + if (ctx.variable() != null) { + builder.appendExpression(visit(ctx.variable())); } - return tokens; + return builder; } @Override - public List visitQueryOrder(HqlParser.QueryOrderContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitSetClause(HqlParser.SetClauseContext ctx) { - tokens.addAll(visit(ctx.orderByClause())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.limitClause() != null) { - SPACE(tokens); - tokens.addAll(visit(ctx.limitClause())); - } - if (ctx.offsetClause() != null) { - tokens.addAll(visit(ctx.offsetClause())); - } - if (ctx.fetchClause() != null) { - tokens.addAll(visit(ctx.fetchClause())); - } - - return tokens; + builder.append(QueryTokens.expression(ctx.SET())); + return builder.append(QueryTokenStream.concat(ctx.assignment(), this::visit, TOKEN_COMMA)); } @Override - public List visitFromClause(HqlParser.FromClauseContext ctx) { + public QueryTokenStream visitAssignment(HqlParser.AssignmentContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - // TODO: Read up on Framework's LeastRecentlyUsedCache - tokens.add(new JpaQueryParsingToken(ctx.FROM())); + builder.append(visit(ctx.simplePath())); + builder.append(TOKEN_EQUALS); + builder.append(visit(ctx.expressionOrPredicate())); - ctx.entityWithJoins().forEach(entityWithJoinsContext -> { - tokens.addAll(visit(entityWithJoinsContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); + return builder; + } - return tokens; + @Override + public QueryTokenStream visitTargetFields(HqlParser.TargetFieldsContext ctx) { + return QueryTokenStream.group(QueryTokenStream.concat(ctx.simplePath(), this::visit, TOKEN_COMMA)); } @Override - public List visitEntityWithJoins(HqlParser.EntityWithJoinsContext ctx) { + public QueryTokenStream visitValuesList(HqlParser.ValuesListContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.fromRoot())); - SPACE(tokens); + builder.append(QueryTokens.expression(ctx.VALUES())); + builder.append(QueryTokenStream.concat(ctx.values(), this::visit, TOKEN_COMMA)); - ctx.joinSpecifier().forEach(joinSpecifierContext -> { - tokens.addAll(visit(joinSpecifierContext)); - }); + return builder; + } - return tokens; + @Override + public QueryTokenStream visitValues(HqlParser.ValuesContext ctx) { + return QueryTokenStream.group(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); } @Override - public List visitJoinSpecifier(HqlParser.JoinSpecifierContext ctx) { + public QueryTokenStream visitConflictTarget(HqlParser.ConflictTargetContext ctx) { - if (ctx.join() != null) { - return visit(ctx.join()); - } else if (ctx.crossJoin() != null) { - return visit(ctx.crossJoin()); - } else if (ctx.jpaCollectionJoin() != null) { - return visit(ctx.jpaCollectionJoin()); - } else { - return List.of(); + if (ctx.identifier() != null) { + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } + + return QueryTokenStream.group(QueryTokenStream.concat(ctx.simplePath(), this::visit, TOKEN_COMMA)); } @Override - public List visitFromRoot(HqlParser.FromRootContext ctx) { + public QueryTokenStream visitInstantiation(HqlParser.InstantiationContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.entityName() != null) { + builder.append(QueryTokens.expression(ctx.NEW())); + builder.append(visit(ctx.instantiationTarget())); + builder.append(QueryTokenStream.group(visit(ctx.instantiationArguments()))); - tokens.addAll(visit(ctx.entityName())); + return builder; + } + public QueryTokenStream visitSelectionList(HqlParser.SelectionListContext ctx) { + return QueryTokenStream.concat(ctx.selection(), this::visit, TOKEN_COMMA); + } - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); - } - NOSPACE(tokens); - } else if (ctx.subquery() != null) { + @Override + public QueryTokenStream visitMapEntrySelection(HqlParser.MapEntrySelectionContext ctx) { - if (ctx.LATERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LATERAL())); - } - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - tokens.add(TOKEN_CLOSE_PAREN); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); - } - } + builder.append(QueryTokens.expression(ctx.ENTRY())); + builder.append(QueryTokenStream.group(visit(ctx.path()))); - return tokens; + return builder; } @Override - public List visitJoin(HqlParser.JoinContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.joinType())); - tokens.add(new JpaQueryParsingToken(ctx.JOIN())); + public QueryTokenStream visitJpaSelectObjectSyntax(HqlParser.JpaSelectObjectSyntaxContext ctx) { + return QueryTokenStream.ofFunction(ctx.OBJECT(), visit(ctx.identifier())); + } - if (ctx.FETCH() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FETCH())); - } + @Override + public QueryTokenStream visitWhereClause(HqlParser.WhereClauseContext ctx) { - tokens.addAll(visit(ctx.joinTarget())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.joinRestriction() != null) { - tokens.addAll(visit(ctx.joinRestriction())); - } + builder.append(QueryTokens.expression(ctx.WHERE())); + builder.append(QueryTokenStream.concatExpressions(ctx.predicate(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitJoinPath(HqlParser.JoinPathContext ctx) { + public QueryTokenStream visitJpaCollectionJoin(HqlParser.JpaCollectionJoinContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.path())); + builder.append(TOKEN_COMMA); + builder.append(QueryTokens.token(ctx.IN())); + builder.append(QueryTokenStream.group(visit(ctx.path()))); if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); + builder.appendExpression(visit(ctx.variable())); } - return tokens; + return builder; } @Override - public List visitJoinSubquery(HqlParser.JoinSubqueryContext ctx) { + public QueryTokenStream visitGroupByClause(HqlParser.GroupByClauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.LATERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LATERAL())); - } + builder.append(QueryTokens.expression(ctx.GROUP())); + builder.append(QueryTokens.expression(ctx.BY())); + builder.append(QueryTokenStream.concat(ctx.groupedItem(), this::visit, TOKEN_COMMA)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - tokens.add(TOKEN_CLOSE_PAREN); + return builder; + } - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); - } + @Override + public QueryTokenStream visitOrderByClause(HqlParser.OrderByClauseContext ctx) { - return tokens; + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(QueryTokens.expression(ctx.ORDER())); + builder.append(QueryTokens.expression(ctx.BY())); + builder.appendExpression(QueryTokenStream.concat(ctx.sortedItem(), this::visit, TOKEN_COMMA)); + + return builder; } @Override - public List visitUpdateStatement(HqlParser.UpdateStatementContext ctx) { + public QueryTokenStream visitHavingClause(HqlParser.HavingClauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.UPDATE())); + builder.append(QueryTokens.expression(ctx.HAVING())); + builder.appendExpression(QueryTokenStream.concat(ctx.predicate(), this::visit, TOKEN_COMMA)); - if (ctx.VERSIONED() != null) { - tokens.add(new JpaQueryParsingToken(ctx.VERSIONED())); - } + return builder; + } - tokens.addAll(visit(ctx.targetEntity())); - tokens.addAll(visit(ctx.setClause())); + @Override + public QueryTokenStream visitLocalDateTimeLiteral(HqlParser.LocalDateTimeLiteralContext ctx) { - if (ctx.whereClause() != null) { - tokens.addAll(visit(ctx.whereClause())); + if (ctx.DATETIME() == null) { + return QueryTokenStream.group(visit(ctx.localDateTime())); } - return tokens; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitTargetEntity(HqlParser.TargetEntityContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitZonedDateTimeLiteral(HqlParser.ZonedDateTimeLiteralContext ctx) { - tokens.addAll(visit(ctx.entityName())); - - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); + if (ctx.DATETIME() == null) { + return QueryTokenStream.group(visit(ctx.zonedDateTime())); } - return tokens; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitSetClause(HqlParser.SetClauseContext ctx) { + public QueryTokenStream visitOffsetDateTimeLiteral(HqlParser.OffsetDateTimeLiteralContext ctx) { - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.SET())); - - ctx.assignment().forEach(assignmentContext -> { - tokens.addAll(visit(assignmentContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + if (ctx.DATETIME() == null) { + return QueryTokenStream.group(visit(ctx.offsetDateTime())); + } - return tokens; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitAssignment(HqlParser.AssignmentContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitDateLiteral(HqlParser.DateLiteralContext ctx) { - tokens.addAll(visit(ctx.simplePath())); - tokens.add(TOKEN_EQUALS); - tokens.addAll(visit(ctx.expressionOrPredicate())); + if (ctx.DATE() == null) { + return QueryTokenStream.group(visit(ctx.date())); + } - return tokens; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitDeleteStatement(HqlParser.DeleteStatementContext ctx) { + public QueryTokenStream visitTimeLiteral(HqlParser.TimeLiteralContext ctx) { - List tokens = new ArrayList<>(); + if (ctx.TIME() == null) { + return QueryTokenStream.group(visit(ctx.time())); + } - tokens.add(new JpaQueryParsingToken(ctx.DELETE())); + return QueryTokenStream.concatExpressions(ctx.children, this::visit); + } - if (ctx.FROM() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - } + @Override + public QueryTokenStream visitOffsetDateTime(HqlParser.OffsetDateTimeContext ctx) { - tokens.addAll(visit(ctx.targetEntity())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.whereClause() != null) { - tokens.addAll(visit(ctx.whereClause())); - } + builder.appendExpression(visit(ctx.date())); + builder.appendInline(visit(ctx.time())); + builder.appendInline(visit(ctx.offset())); - return tokens; + return builder; } @Override - public List visitInsertStatement(HqlParser.InsertStatementContext ctx) { + public QueryTokenStream visitOffsetDateTimeWithMinutes(HqlParser.OffsetDateTimeWithMinutesContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.INSERT())); + builder.appendExpression(visit(ctx.date())); + builder.appendInline(visit(ctx.time())); + builder.appendInline(visit(ctx.offsetWithMinutes())); - if (ctx.INTO() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INTO())); - } + return builder; + } + + @Override + public QueryTokenStream visitJdbcTimestampLiteral(HqlParser.JdbcTimestampLiteralContext ctx) { - tokens.addAll(visit(ctx.targetEntity())); - tokens.addAll(visit(ctx.targetFields())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.queryExpression() != null) { - tokens.addAll(visit(ctx.queryExpression())); - } else if (ctx.valuesList() != null) { - tokens.addAll(visit(ctx.valuesList())); - } + builder.append(TOKEN_OPEN_BRACE); + builder.append(QueryTokens.token("ts")); + builder.appendInline(visit(ctx.dateTime() != null ? ctx.dateTime() : ctx.genericTemporalLiteralText())); + builder.append(QueryTokens.TOKEN_CLOSE_BRACE); - return tokens; + return builder; } @Override - public List visitTargetFields(HqlParser.TargetFieldsContext ctx) { + public QueryTokenStream visitJdbcDateLiteral(HqlParser.JdbcDateLiteralContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(TOKEN_OPEN_PAREN); + builder.append(TOKEN_OPEN_BRACE); + builder.append(QueryTokens.token("d")); + builder.append(visit(ctx.date() != null ? ctx.date() : ctx.genericTemporalLiteralText())); + builder.append(QueryTokens.TOKEN_CLOSE_BRACE); - ctx.simplePath().forEach(simplePathContext -> { - tokens.addAll(visit(simplePathContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; + return builder; } @Override - public List visitValuesList(HqlParser.ValuesListContext ctx) { + public QueryTokenStream visitJdbcTimeLiteral(HqlParser.JdbcTimeLiteralContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.VALUES())); + builder.append(TOKEN_OPEN_BRACE); + builder.append(QueryTokens.token("t")); + builder.append(visit(ctx.time() != null ? ctx.time() : ctx.genericTemporalLiteralText())); + builder.append(QueryTokens.TOKEN_CLOSE_BRACE); - ctx.values().forEach(valuesContext -> { - tokens.addAll(visit(valuesContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - - return tokens; + return builder; } @Override - public List visitValues(HqlParser.ValuesContext ctx) { + public QueryTokenStream visitArrayLiteral(HqlParser.ArrayLiteralContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(TOKEN_OPEN_SQUARE_BRACKET); + builder.append(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_SQUARE_BRACKET); - tokens.add(TOKEN_OPEN_PAREN); + return builder; + } - ctx.expression().forEach(expressionContext -> { - tokens.addAll(visit(expressionContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + @Override + public QueryTokenStream visitGeneralizedLiteral(HqlParser.GeneralizedLiteralContext ctx) { - tokens.add(TOKEN_CLOSE_PAREN); + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.generalizedLiteralType())); + builder.append(TOKEN_COLON); + builder.append(visit(ctx.generalizedLiteralText())); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitInstantiation(HqlParser.InstantiationContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitDate(HqlParser.DateContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.NEW())); - tokens.addAll(visit(ctx.instantiationTarget())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.instantiationArguments())); - tokens.add(TOKEN_CLOSE_PAREN); + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(visit(ctx.year())); + builder.append(TOKEN_DASH); + builder.append(visit(ctx.month())); + builder.append(TOKEN_DASH); + builder.append(visit(ctx.day())); - return tokens; + return builder; } @Override - public List visitAlias(HqlParser.AliasContext ctx) { + public QueryTokenStream visitTime(HqlParser.TimeContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(visit(ctx.hour())); + builder.append(TOKEN_COLON); + builder.append(visit(ctx.minute())); - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); + if (ctx.second() != null) { + builder.append(TOKEN_COLON); + builder.append(visit(ctx.second())); } - tokens.addAll(visit(ctx.identifier())); - - return tokens; + return builder; } @Override - public List visitGroupedItem(HqlParser.GroupedItemContext ctx) { + public QueryTokenStream visitOffset(HqlParser.OffsetContext ctx) { - if (ctx.identifier() != null) { - return visit(ctx.identifier()); - } else if (ctx.INTEGER_LITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.INTEGER_LITERAL())); - } else if (ctx.expression() != null) { - return visit(ctx.expression()); - } else { - return List.of(); + QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.MINUS() != null) { + builder.append(QueryTokens.token(ctx.MINUS())); + } else if (ctx.PLUS() != null) { + builder.append(QueryTokens.token(ctx.PLUS())); + } + builder.append(visit(ctx.hour())); + + if (ctx.minute() != null) { + builder.append(TOKEN_COLON); + builder.append(visit(ctx.minute())); } + + return builder; } @Override - public List visitSortedItem(HqlParser.SortedItemContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitOffsetWithMinutes(HqlParser.OffsetWithMinutesContext ctx) { - tokens.addAll(visit(ctx.sortExpression())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.sortDirection() != null) { - tokens.addAll(visit(ctx.sortDirection())); + if (ctx.MINUS() != null) { + builder.append(QueryTokens.token(ctx.MINUS())); + } else if (ctx.PLUS() != null) { + builder.append(QueryTokens.token(ctx.PLUS())); } - if (ctx.nullsPrecedence() != null) { - tokens.addAll(visit(ctx.nullsPrecedence())); - } + builder.append(visit(ctx.hour())); + builder.append(TOKEN_COLON); + builder.append(visit(ctx.minute())); - return tokens; + return builder; } @Override - public List visitSortExpression(HqlParser.SortExpressionContext ctx) { + public QueryTokenStream visitBinaryLiteral(HqlParser.BinaryLiteralContext ctx) { - if (ctx.identifier() != null) { - return visit(ctx.identifier()); - } else if (ctx.INTEGER_LITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.INTEGER_LITERAL())); - } else if (ctx.expression() != null) { - return visit(ctx.expression()); - } else { - return List.of(); - } - } + QueryRendererBuilder builder = QueryRenderer.builder(); - @Override - public List visitSortDirection(HqlParser.SortDirectionContext ctx) { + if (ctx.BINARY_LITERAL() != null) { + builder.append(QueryTokens.expression(ctx.BINARY_LITERAL())); + } else if (ctx.HEX_LITERAL() != null) { - if (ctx.ASC() != null) { - return List.of(new JpaQueryParsingToken(ctx.ASC())); - } else if (ctx.DESC() != null) { - return List.of(new JpaQueryParsingToken(ctx.DESC())); - } else { - return List.of(); + builder.append(TOKEN_OPEN_BRACE); + builder.append(QueryTokenStream.concat(ctx.HEX_LITERAL(), QueryTokens::token, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_BRACE); } + + return builder; } @Override - public List visitNullsPrecedence(HqlParser.NullsPrecedenceContext ctx) { + public QueryTokenStream visitTupleExpression(HqlParser.TupleExpressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.NULLS())); + builder.append(TOKEN_OPEN_PAREN); + builder.append(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_PAREN); - if (ctx.FIRST() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FIRST())); - } else if (ctx.LAST() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LAST())); - } - - return tokens; + return builder; } @Override - public List visitLimitClause(HqlParser.LimitClauseContext ctx) { + public QueryTokenStream visitHqlConcatenationExpression(HqlParser.HqlConcatenationExpressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.LIMIT())); - tokens.addAll(visit(ctx.parameterOrIntegerLiteral())); + builder.appendInline(visit(ctx.expression(0))); + builder.append(TOKEN_DOUBLE_PIPE); + builder.append(visit(ctx.expression(1))); - return tokens; + return builder; } @Override - public List visitOffsetClause(HqlParser.OffsetClauseContext ctx) { + public QueryTokenStream visitGroupedExpression(HqlParser.GroupedExpressionContext ctx) { + return QueryTokenStream.group(visit(ctx.expression())); + } - List tokens = new ArrayList<>(); + @Override + public QueryTokenStream visitSignedNumericLiteral(HqlParser.SignedNumericLiteralContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.OFFSET())); - tokens.addAll(visit(ctx.parameterOrIntegerLiteral())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.ROW() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ROW())); - } else if (ctx.ROWS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ROWS())); - } + builder.append(QueryTokens.token(ctx.op)); + builder.append(visit(ctx.numericLiteral())); - return tokens; + return builder; } @Override - public List visitFetchClause(HqlParser.FetchClauseContext ctx) { + public QueryTokenStream visitSubqueryExpression(HqlParser.SubqueryExpressionContext ctx) { + return QueryTokenStream.group(visit(ctx.subquery())); + } - List tokens = new ArrayList<>(); + @Override + public QueryTokenStream visitSignedExpression(HqlParser.SignedExpressionContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.FETCH())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.FIRST() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FIRST())); - } else if (ctx.NEXT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NEXT())); - } + builder.append(QueryTokens.token(ctx.op)); + builder.appendInline(visit(ctx.expression())); - if (ctx.parameterOrIntegerLiteral() != null) { - tokens.addAll(visit(ctx.parameterOrIntegerLiteral())); - } else if (ctx.parameterOrNumberLiteral() != null) { + return builder; + } - tokens.addAll(visit(ctx.parameterOrNumberLiteral())); - tokens.add(TOKEN_PERCENT); - } + @Override + public QueryTokenStream visitSyntacticPathExpression(HqlParser.SyntacticPathExpressionContext ctx) { - if (ctx.ROW() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ROW())); - } else if (ctx.ROWS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ROWS())); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.ONLY() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ONLY())); - } else if (ctx.WITH() != null) { + builder.appendInline(visit(ctx.syntacticDomainPath())); - tokens.add(new JpaQueryParsingToken(ctx.WITH())); - tokens.add(new JpaQueryParsingToken(ctx.TIES())); + if (ctx.pathContinuation() != null) { + builder.appendInline(visit(ctx.pathContinuation())); } - return tokens; + return builder; } @Override - public List visitSubquery(HqlParser.SubqueryContext ctx) { - return visit(ctx.queryExpression()); + public QueryTokenStream visitPathContinuation(HqlParser.PathContinuationContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(TOKEN_DOT); + builder.append(visit(ctx.simplePath())); + + return builder; } @Override - public List visitSelectClause(HqlParser.SelectClauseContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitEntityTypeReference(HqlParser.EntityTypeReferenceContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.SELECT())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); + if (ctx.path() != null) { + builder.appendInline(visit(ctx.path())); } - tokens.addAll(visit(ctx.selectionList())); + if (ctx.parameter() != null) { + builder.appendInline(visit(ctx.parameter())); + } - return tokens; + return QueryTokenStream.ofFunction(ctx.TYPE(), builder); } @Override - public List visitSelectionList(HqlParser.SelectionListContext ctx) { + public QueryTokenStream visitEntityIdReference(HqlParser.EntityIdReferenceContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); - List tokens = new ArrayList<>(); + builder.append(QueryTokens.token(ctx.ID())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.path())); + builder.append(TOKEN_CLOSE_PAREN); - ctx.selection().forEach(selectionContext -> { - tokens.addAll(visit(selectionContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); + if (ctx.pathContinuation() != null) { + builder.appendInline(visit(ctx.pathContinuation())); + } - return tokens; + return builder; } @Override - public List visitSelection(HqlParser.SelectionContext ctx) { + public QueryTokenStream visitEntityVersionReference(HqlParser.EntityVersionReferenceContext ctx) { + return QueryTokenStream.ofFunction(ctx.VERSION(), visit(ctx.path())); + } - List tokens = new ArrayList<>(); + @Override + public QueryTokenStream visitEntityNaturalIdReference(HqlParser.EntityNaturalIdReferenceContext ctx) { - tokens.addAll(visit(ctx.selectExpression())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); + builder.append(QueryTokens.token(ctx.NATURALID())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.path())); + builder.append(TOKEN_CLOSE_PAREN); + + if (ctx.pathContinuation() != null) { + builder.appendInline(visit(ctx.pathContinuation())); } - return tokens; + return builder; } @Override - public List visitSelectExpression(HqlParser.SelectExpressionContext ctx) { + public QueryTokenStream visitSyntacticDomainPath(HqlParser.SyntacticDomainPathContext ctx) { - if (ctx.instantiation() != null) { - return visit(ctx.instantiation()); - } else if (ctx.mapEntrySelection() != null) { - return visit(ctx.mapEntrySelection()); - } else if (ctx.jpaSelectObjectSyntax() != null) { - return visit(ctx.jpaSelectObjectSyntax()); - } else if (ctx.expressionOrPredicate() != null) { - return visit(ctx.expressionOrPredicate()); - } else { - return List.of(); + if (ctx.treatedNavigablePath() != null) { + return visit(ctx.treatedNavigablePath()); } - } - @Override - public List visitMapEntrySelection(HqlParser.MapEntrySelectionContext ctx) { + if (ctx.collectionValueNavigablePath() != null) { + return visit(ctx.collectionValueNavigablePath()); + } - List tokens = new ArrayList<>(); + if (ctx.mapKeyNavigablePath() != null) { + return visit(ctx.mapKeyNavigablePath()); + } - tokens.add(new JpaQueryParsingToken(ctx.ENTRY())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.path())); - tokens.add(TOKEN_CLOSE_PAREN); + if (ctx.toOneFkReference() != null) { + return visit(ctx.toOneFkReference()); + } - return tokens; - } + if (ctx.function() != null) { - @Override - public List visitJpaSelectObjectSyntax(HqlParser.JpaSelectObjectSyntaxContext ctx) { + QueryRendererBuilder builder = QueryRenderer.builder(); - List tokens = new ArrayList<>(); + builder.append(visit(ctx.function())); - tokens.add(new JpaQueryParsingToken(ctx.OBJECT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identifier())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + if (ctx.indexedPathAccessFragment() != null) { + builder.append(visit(ctx.indexedPathAccessFragment())); + } - return tokens; - } + if (ctx.slicedPathAccessFragment() != null) { + builder.append(visit(ctx.slicedPathAccessFragment())); + } - @Override - public List visitWhereClause(HqlParser.WhereClauseContext ctx) { + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); + } - List tokens = new ArrayList<>(); + return builder; + } - tokens.add(new JpaQueryParsingToken(ctx.WHERE())); + if (ctx.indexedPathAccessFragment() != null) { - ctx.predicate().forEach(predicateContext -> { - tokens.addAll(visit(predicateContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + QueryRendererBuilder builder = QueryRenderer.builder(); - return tokens; - } + builder.append(visit(ctx.simplePath())); + builder.append(visit(ctx.indexedPathAccessFragment())); - @Override - public List visitJoinType(HqlParser.JoinTypeContext ctx) { + return builder; + } - List tokens = new ArrayList<>(); + if (ctx.slicedPathAccessFragment() != null) { - if (ctx.INNER() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INNER())); - } - if (ctx.LEFT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LEFT())); - } - if (ctx.RIGHT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.RIGHT())); - } - if (ctx.FULL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FULL())); - } - if (ctx.OUTER() != null) { - tokens.add(new JpaQueryParsingToken(ctx.OUTER())); - } - if (ctx.CROSS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CROSS())); + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(visit(ctx.simplePath())); + builder.append(visit(ctx.slicedPathAccessFragment())); + + return builder; } - return tokens; + return QueryRenderer.empty(); } @Override - public List visitCrossJoin(HqlParser.CrossJoinContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitSlicedPathAccessFragment(HqlParser.SlicedPathAccessFragmentContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.CROSS())); - tokens.add(new JpaQueryParsingToken(ctx.JOIN())); - tokens.addAll(visit(ctx.entityName())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); - } + builder.append(TOKEN_OPEN_SQUARE_BRACKET); + builder.appendInline(visit(ctx.expression(0))); + builder.append(TOKEN_COLON); + builder.appendInline(visit(ctx.expression(1))); + builder.append(TOKEN_CLOSE_SQUARE_BRACKET); - return tokens; + return builder; } @Override - public List visitJoinRestriction(HqlParser.JoinRestrictionContext ctx) { + public QueryTokenStream visitSubstringFunction(HqlParser.SubstringFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.ON() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ON())); - } else if (ctx.WITH() != null) { - tokens.add(new JpaQueryParsingToken(ctx.WITH())); + if (ctx.FROM() == null) { + builder.appendInline(visit(ctx.expression())); + builder.append(TOKEN_COMMA); + } else { + builder.appendExpression(visit(ctx.expression())); + builder.append(QueryTokens.expression(ctx.FROM())); } - tokens.addAll(visit(ctx.predicate())); + if (ctx.substringFunctionLengthArgument() != null) { + + if (ctx.FOR() == null) { + builder.appendInline(visit(ctx.substringFunctionStartArgument())); + builder.append(TOKEN_COMMA); + } else { + builder.appendExpression(visit(ctx.substringFunctionStartArgument())); + builder.append(QueryTokens.expression(ctx.FOR())); + } + + builder.append(visit(ctx.substringFunctionLengthArgument())); + } else { + builder.appendExpression(visit(ctx.substringFunctionStartArgument())); + } - return tokens; + return QueryTokenStream.ofFunction(ctx.SUBSTRING(), builder); } @Override - public List visitJpaCollectionJoin(HqlParser.JpaCollectionJoinContext ctx) { + public QueryTokenStream visitPadFunction(HqlParser.PadFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(TOKEN_COMMA); - tokens.add(new JpaQueryParsingToken(ctx.IN(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.path())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.appendExpression(visit(ctx.expression())); + builder.append(QueryTokens.expression(ctx.WITH())); + builder.appendExpression(visit(ctx.padLength())); - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); + if (ctx.padCharacter() != null) { + builder.appendExpression(visit(ctx.padSpecification())); + builder.appendExpression(visit(ctx.padCharacter())); + } else { + builder.appendExpression(visit(ctx.padSpecification())); } - return tokens; + return QueryTokenStream.ofFunction(ctx.PAD(), builder); } @Override - public List visitGroupByClause(HqlParser.GroupByClauseContext ctx) { + public QueryTokenStream visitPositionFunction(HqlParser.PositionFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder nested = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.GROUP())); - tokens.add(new JpaQueryParsingToken(ctx.BY())); + nested.appendExpression(visit(ctx.positionFunctionPatternArgument())); + nested.append(QueryTokens.expression(ctx.IN())); + nested.append(visit(ctx.positionFunctionStringArgument())); - ctx.groupedItem().forEach(groupedItemContext -> { - tokens.addAll(visit(groupedItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); - - return tokens; + return QueryTokenStream.ofFunction(ctx.POSITION(), nested); } @Override - public List visitOrderByClause(HqlParser.OrderByClauseContext ctx) { + public QueryTokenStream visitOverlayFunction(HqlParser.OverlayFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.ORDER())); - tokens.add(new JpaQueryParsingToken(ctx.BY())); + builder.append(QueryTokens.token(ctx.OVERLAY())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendExpression(visit(ctx.overlayFunctionStringArgument())); + builder.append(QueryTokens.expression(ctx.PLACING())); + builder.append(visit(ctx.overlayFunctionReplacementArgument())); + builder.append(QueryTokens.expression(ctx.FROM())); + builder.append(visit(ctx.overlayFunctionStartArgument())); - ctx.sortedItem().forEach(sortedItemContext -> { - tokens.addAll(visit(sortedItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + if (ctx.overlayFunctionLengthArgument() != null) { + builder.append(QueryTokens.expression(ctx.FOR())); + builder.append(visit(ctx.overlayFunctionLengthArgument())); + } + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitHavingClause(HqlParser.HavingClauseContext ctx) { + public QueryTokenStream visitCurrentDateFunction(HqlParser.CurrentDateFunctionContext ctx) { - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.HAVING())); - - ctx.predicate().forEach(predicateContext -> { - tokens.addAll(visit(predicateContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + if (ctx.CURRENT_DATE() != null) { + return QueryTokenStream.ofFunction(ctx.CURRENT_DATE(), QueryTokenStream.empty()); + } - return tokens; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitSetOperator(HqlParser.SetOperatorContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitCurrentTimeFunction(HqlParser.CurrentTimeFunctionContext ctx) { - if (ctx.UNION() != null) { - tokens.add(new JpaQueryParsingToken(ctx.UNION())); - } else if (ctx.INTERSECT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INTERSECT())); - } else if (ctx.EXCEPT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.EXCEPT())); + if (ctx.CURRENT_TIME() != null) { + return QueryTokenStream.ofFunction(ctx.CURRENT_TIME(), QueryTokenStream.empty()); } - if (ctx.ALL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ALL())); + return QueryTokenStream.concatExpressions(ctx.children, this::visit); + } + + @Override + public QueryTokenStream visitCurrentTimestampFunction(HqlParser.CurrentTimestampFunctionContext ctx) { + + if (ctx.CURRENT_TIMESTAMP() != null) { + return QueryTokenStream.ofFunction(ctx.CURRENT_TIMESTAMP(), QueryTokenStream.empty()); } - return tokens; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitLiteral(HqlParser.LiteralContext ctx) { + public QueryTokenStream visitInstantFunction(HqlParser.InstantFunctionContext ctx) { - if (ctx.NULL() != null) { - return List.of(new JpaQueryParsingToken(ctx.NULL())); - } else if (ctx.booleanLiteral() != null) { - return visit(ctx.booleanLiteral()); - } else if (ctx.stringLiteral() != null) { - return visit(ctx.stringLiteral()); - } else if (ctx.numericLiteral() != null) { - return visit(ctx.numericLiteral()); - } else if (ctx.dateTimeLiteral() != null) { - return visit(ctx.dateTimeLiteral()); - } else if (ctx.binaryLiteral() != null) { - return visit(ctx.binaryLiteral()); - } else { - return List.of(); + if (ctx.CURRENT_INSTANT() != null) { + return QueryTokenStream.ofFunction(ctx.CURRENT_INSTANT(), QueryTokenStream.empty()); } + + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitBooleanLiteral(HqlParser.BooleanLiteralContext ctx) { + public QueryTokenStream visitLocalDateTimeFunction(HqlParser.LocalDateTimeFunctionContext ctx) { - if (ctx.TRUE() != null) { - return List.of(new JpaQueryParsingToken(ctx.TRUE())); - } else if (ctx.FALSE() != null) { - return List.of(new JpaQueryParsingToken(ctx.FALSE())); - } else { - return List.of(); + if (ctx.LOCAL_DATETIME() != null) { + return QueryTokenStream.ofFunction(ctx.LOCAL_DATETIME(), QueryTokenStream.empty()); } + + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitStringLiteral(HqlParser.StringLiteralContext ctx) { + public QueryTokenStream visitOffsetDateTimeFunction(HqlParser.OffsetDateTimeFunctionContext ctx) { - if (ctx.STRINGLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.STRINGLITERAL())); - } else if (ctx.CHARACTER() != null) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); - } else { - return List.of(); + if (ctx.OFFSET_DATETIME() != null) { + return QueryTokenStream.ofFunction(ctx.OFFSET_DATETIME(), QueryTokenStream.empty()); } + + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitNumericLiteral(HqlParser.NumericLiteralContext ctx) { + public QueryTokenStream visitLocalDateFunction(HqlParser.LocalDateFunctionContext ctx) { - if (ctx.INTEGER_LITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.INTEGER_LITERAL())); - } else if (ctx.FLOAT_LITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.FLOAT_LITERAL())); - } else if (ctx.HEXLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.HEXLITERAL())); - } else { - return List.of(); + if (ctx.LOCAL_DATE() != null) { + return QueryTokenStream.ofFunction(ctx.LOCAL_DATE(), QueryTokenStream.empty()); } + + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public List visitDateTimeLiteral(HqlParser.DateTimeLiteralContext ctx) { + public QueryTokenStream visitLocalTimeFunction(HqlParser.LocalTimeFunctionContext ctx) { - List tokens = new ArrayList<>(); + if (ctx.LOCAL_TIME() != null) { + return QueryTokenStream.ofFunction(ctx.LOCAL_TIME(), QueryTokenStream.empty()); + } - if (ctx.LOCAL_DATE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LOCAL_DATE())); - } else if (ctx.LOCAL_TIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LOCAL_TIME())); - } else if (ctx.LOCAL_DATETIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LOCAL_DATETIME())); - } else if (ctx.CURRENT_DATE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT_DATE())); - } else if (ctx.CURRENT_TIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT_TIME())); - } else if (ctx.CURRENT_TIMESTAMP() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT_TIMESTAMP())); - } else if (ctx.OFFSET_DATETIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.OFFSET_DATETIME())); - } else { + return QueryTokenStream.concatExpressions(ctx.children, this::visit); + } - if (ctx.LOCAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LOCAL())); - } else if (ctx.CURRENT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT())); - } else if (ctx.OFFSET() != null) { - tokens.add(new JpaQueryParsingToken(ctx.OFFSET())); - } + @Override + public QueryTokenStream visitFormatFunction(HqlParser.FormatFunctionContext ctx) { - if (ctx.DATE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DATE())); - } else if (ctx.TIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.TIME())); - } else if (ctx.DATETIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DATETIME())); - } + QueryRendererBuilder args = QueryRenderer.builder(); - if (ctx.INSTANT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INSTANT())); - } - } + args.appendExpression(visit(ctx.expression())); + args.append(QueryTokens.expression(ctx.AS())); + args.appendExpression(visit(ctx.format())); - return tokens; - } - - @Override - public List visitDatetimeField(HqlParser.DatetimeFieldContext ctx) { - - if (ctx.YEAR() != null) { - return List.of(new JpaQueryParsingToken(ctx.YEAR())); - } else if (ctx.MONTH() != null) { - return List.of(new JpaQueryParsingToken(ctx.MONTH())); - } else if (ctx.DAY() != null) { - return List.of(new JpaQueryParsingToken(ctx.DAY())); - } else if (ctx.WEEK() != null) { - return List.of(new JpaQueryParsingToken(ctx.WEEK())); - } else if (ctx.QUARTER() != null) { - return List.of(new JpaQueryParsingToken(ctx.QUARTER())); - } else if (ctx.HOUR() != null) { - return List.of(new JpaQueryParsingToken(ctx.HOUR())); - } else if (ctx.MINUTE() != null) { - return List.of(new JpaQueryParsingToken(ctx.MINUTE())); - } else if (ctx.SECOND() != null) { - return List.of(new JpaQueryParsingToken(ctx.SECOND())); - } else if (ctx.NANOSECOND() != null) { - return List.of(new JpaQueryParsingToken(ctx.NANOSECOND())); - } else if (ctx.EPOCH() != null) { - return List.of(new JpaQueryParsingToken(ctx.EPOCH())); - } else { - return List.of(); - } + return QueryTokenStream.ofFunction(ctx.FORMAT(), args); } @Override - public List visitBinaryLiteral(HqlParser.BinaryLiteralContext ctx) { + public QueryTokenStream visitCollateFunction(HqlParser.CollateFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder args = QueryRenderer.builder(); - if (ctx.BINARY_LITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.BINARY_LITERAL())); - } else if (ctx.HEXLITERAL() != null) { + args.appendExpression(visit(ctx.expression())); + args.append(QueryTokens.expression(ctx.AS())); + args.appendExpression(visit(ctx.collation())); - tokens.add(TOKEN_OPEN_BRACE); - ctx.HEXLITERAL().forEach(terminalNode -> { - tokens.add(new JpaQueryParsingToken(terminalNode)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - tokens.add(TOKEN_CLOSE_BRACE); - } + return QueryTokenStream.ofFunction(ctx.COLLATE(), args); + } - return tokens; + @Override + public QueryTokenStream visitCube(HqlParser.CubeContext ctx) { + return QueryTokenStream.ofFunction(ctx.CUBE(), + QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); } @Override - public List visitPlainPrimaryExpression(HqlParser.PlainPrimaryExpressionContext ctx) { - return visit(ctx.primaryExpression()); + public QueryTokenStream visitRollup(HqlParser.RollupContext ctx) { + return QueryTokenStream.ofFunction(ctx.ROLLUP(), + QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); } @Override - public List visitTupleExpression(HqlParser.TupleExpressionContext ctx) { + public QueryTokenStream visitTruncFunction(HqlParser.TruncFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(TOKEN_OPEN_PAREN); + if (ctx.TRUNC() != null) { + builder.append(QueryTokens.token(ctx.TRUNC())); + } else { + builder.append(QueryTokens.token(ctx.TRUNCATE())); + } - ctx.expressionOrPredicate().forEach(expressionOrPredicateContext -> { - tokens.addAll(visit(expressionOrPredicateContext)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + builder.append(TOKEN_OPEN_PAREN); - tokens.add(TOKEN_CLOSE_PAREN); + if (ctx.datetimeField() != null) { + builder.append(visit(ctx.expression(0))); + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.datetimeField())); + } else { + builder.append(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); + } + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitHqlConcatenationExpression(HqlParser.HqlConcatenationExpressionContext ctx) { + public QueryTokenStream visitJpaNonstandardFunction(HqlParser.JpaNonstandardFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.expression(0))); - tokens.add(TOKEN_DOUBLE_PIPE); - tokens.addAll(visit(ctx.expression(1))); + builder.append(QueryTokens.token(ctx.FUNCTION())); + builder.append(TOKEN_OPEN_PAREN); - return tokens; - } + QueryRendererBuilder nested = QueryRenderer.builder(); + nested.appendInline(visit(ctx.jpaNonstandardFunctionName())); - @Override - public List visitDayOfWeekExpression(HqlParser.DayOfWeekExpressionContext ctx) { + if (ctx.castTarget() != null) { + nested.append(QueryTokens.expression(ctx.AS())); + nested.append(visit(ctx.castTarget())); + } - List tokens = new ArrayList<>(); + if (ctx.genericFunctionArguments() != null) { + nested.append(TOKEN_COMMA); + nested.appendInline(visit(ctx.genericFunctionArguments())); + } - tokens.add(new JpaQueryParsingToken(ctx.DAY())); - tokens.add(new JpaQueryParsingToken(ctx.OF())); - tokens.add(new JpaQueryParsingToken(ctx.WEEK())); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitDayOfMonthExpression(HqlParser.DayOfMonthExpressionContext ctx) { + public QueryTokenStream visitColumnFunction(HqlParser.ColumnFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.DAY())); - tokens.add(new JpaQueryParsingToken(ctx.OF())); - tokens.add(new JpaQueryParsingToken(ctx.MONTH())); + builder.append(QueryTokens.token(ctx.COLUMN())); + builder.append(TOKEN_OPEN_PAREN); - return tokens; - } + QueryRendererBuilder nested = QueryRenderer.builder(); + nested.appendInline(visit(ctx.path())); + nested.append(TOKEN_DOT); + nested.appendExpression(visit(ctx.jpaNonstandardFunctionName())); - @Override - public List visitWeekOfYearExpression(HqlParser.WeekOfYearExpressionContext ctx) { - - List tokens = new ArrayList<>(); + if (ctx.castTarget() != null) { + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.jpaNonstandardFunctionName())); + } - tokens.add(new JpaQueryParsingToken(ctx.WEEK())); - tokens.add(new JpaQueryParsingToken(ctx.OF())); - tokens.add(new JpaQueryParsingToken(ctx.YEAR())); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitGroupedExpression(HqlParser.GroupedExpressionContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitGenericFunctionArguments(HqlParser.GenericFunctionArgumentsContext ctx) { - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } + QueryRendererBuilder builder = QueryRenderer.builder(); - @Override - public List visitAdditionExpression(HqlParser.AdditionExpressionContext ctx) { + if (ctx.DISTINCT() != null) { + builder.append(QueryTokens.expression(ctx.DISTINCT())); + } - List tokens = new ArrayList<>(); + if (ctx.datetimeField() != null) { + builder.append(visit(ctx.datetimeField())); + builder.append(TOKEN_COMMA); + } - tokens.addAll(visit(ctx.expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - tokens.addAll(visit(ctx.expression(1))); + builder.append(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitSignedNumericLiteral(HqlParser.SignedNumericLiteralContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.op, false)); - tokens.addAll(visit(ctx.numericLiteral())); - - return tokens; + public QueryTokenStream visitCollectionSizeFunction(HqlParser.CollectionSizeFunctionContext ctx) { + return QueryTokenStream.ofFunction(ctx.SIZE(), visit(ctx.path())); } @Override - public List visitMultiplicationExpression(HqlParser.MultiplicationExpressionContext ctx) { + public QueryTokenStream visitElementAggregateFunction(HqlParser.ElementAggregateFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.expression(0))); - NOSPACE(tokens); - tokens.add(new JpaQueryParsingToken(ctx.op, false)); - tokens.addAll(visit(ctx.expression(1))); + if (ctx.MAXELEMENT() != null || ctx.MINELEMENT() != null) { + builder.append(QueryTokens.token(ctx.MAXELEMENT() != null ? ctx.MAXELEMENT() : ctx.MINELEMENT())); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.path())); + builder.append(TOKEN_CLOSE_PAREN); + } else { - return tokens; - } + if (ctx.MAX() != null) { + builder.append(QueryTokens.token(ctx.MAX())); + } + if (ctx.MIN() != null) { + builder.append(QueryTokens.token(ctx.MIN())); + } + if (ctx.SUM() != null) { + builder.append(QueryTokens.token(ctx.SUM())); + } + if (ctx.AVG() != null) { + builder.append(QueryTokens.token(ctx.AVG())); + } - @Override - public List visitSubqueryExpression(HqlParser.SubqueryExpressionContext ctx) { + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.elementsValuesQuantifier())); + builder.append(TOKEN_OPEN_PAREN); - List tokens = new ArrayList<>(); + if (ctx.path() != null) { + builder.append(visit(ctx.path())); + } - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_CLOSE_PAREN); + } - return tokens; + return builder; } @Override - public List visitSignedExpression(HqlParser.SignedExpressionContext ctx) { + public QueryTokenStream visitIndexAggregateFunction(HqlParser.IndexAggregateFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.op, false)); - tokens.addAll(visit(ctx.expression())); + if (ctx.MAXINDEX() != null || ctx.MININDEX() != null) { + builder.append(QueryTokens.token(ctx.MAXINDEX() != null ? ctx.MAXINDEX() : ctx.MININDEX())); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.path())); + builder.append(TOKEN_CLOSE_PAREN); + } else { - return tokens; - } + if (ctx.MAX() != null) { + builder.append(QueryTokens.token(ctx.MAX())); + } + if (ctx.MIN() != null) { + builder.append(QueryTokens.token(ctx.MIN())); + } + if (ctx.SUM() != null) { + builder.append(QueryTokens.token(ctx.SUM())); + } + if (ctx.AVG() != null) { + builder.append(QueryTokens.token(ctx.AVG())); + } - @Override - public List visitToDurationExpression(HqlParser.ToDurationExpressionContext ctx) { + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.indicesKeysQuantifier())); + builder.append(TOKEN_OPEN_PAREN); - List tokens = new ArrayList<>(); + if (ctx.path() != null) { + builder.append(visit(ctx.path())); + } - tokens.addAll(visit(ctx.expression())); - tokens.addAll(visit(ctx.datetimeField())); + builder.append(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_CLOSE_PAREN); + } - return tokens; + return builder; } @Override - public List visitFromDurationExpression(HqlParser.FromDurationExpressionContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitCollectionFunctionMisuse(HqlParser.CollectionFunctionMisuseContext ctx) { - tokens.addAll(visit(ctx.expression())); - tokens.add(new JpaQueryParsingToken(ctx.BY())); - tokens.addAll(visit(ctx.datetimeField())); + QueryRendererBuilder builder = QueryRenderer.builder(); - return tokens; - } + builder.append( + visit(ctx.elementsValuesQuantifier() != null ? ctx.elementsValuesQuantifier() : ctx.indicesKeysQuantifier())); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.path())); + builder.append(TOKEN_CLOSE_PAREN); - @Override - public List visitCaseExpression(HqlParser.CaseExpressionContext ctx) { - return visit(ctx.caseList()); - } - - @Override - public List visitLiteralExpression(HqlParser.LiteralExpressionContext ctx) { - return visit(ctx.literal()); + return builder; } @Override - public List visitParameterExpression(HqlParser.ParameterExpressionContext ctx) { - return visit(ctx.parameter()); - } + public QueryTokenStream visitListaggFunction(HqlParser.ListaggFunctionContext ctx) { - @Override - public List visitFunctionExpression(HqlParser.FunctionExpressionContext ctx) { - return visit(ctx.function()); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - @Override - public List visitGeneralPathExpression(HqlParser.GeneralPathExpressionContext ctx) { - return visit(ctx.generalPathFragment()); - } + builder.append(QueryTokens.token(ctx.LISTAGG())); + builder.append(TOKEN_OPEN_PAREN); - @Override - public List visitIdentificationVariable(HqlParser.IdentificationVariableContext ctx) { + QueryRendererBuilder nested = QueryRenderer.builder(); - if (ctx.identifier() != null) { - return visit(ctx.identifier()); - } else if (ctx.simplePath() != null) { - return visit(ctx.simplePath()); - } else { - return List.of(); + if (ctx.DISTINCT() != null) { + builder.append(QueryTokens.expression(ctx.DISTINCT())); } - } - @Override - public List visitPath(HqlParser.PathContext ctx) { + builder.appendInline(visit(ctx.expressionOrPredicate(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.expressionOrPredicate(1))); - List tokens = new ArrayList<>(); - - if (ctx.treatedPath() != null) { - - tokens.addAll(visit(ctx.treatedPath())); - - if (ctx.pathContinutation() != null) { - NOSPACE(tokens); - tokens.addAll(visit(ctx.pathContinutation())); - } - } else if (ctx.generalPathFragment() != null) { - tokens.addAll(visit(ctx.generalPathFragment())); + if (ctx.onOverflowClause() != null) { + builder.appendExpression(visit(ctx.onOverflowClause())); } - return tokens; - } - - @Override - public List visitGeneralPathFragment(HqlParser.GeneralPathFragmentContext ctx) { + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); - List tokens = new ArrayList<>(); + if (ctx.withinGroupClause() != null) { + builder.appendExpression(visit(ctx.withinGroupClause())); + } - tokens.addAll(visit(ctx.simplePath())); + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); + } - if (ctx.indexedPathAccessFragment() != null) { - tokens.addAll(visit(ctx.indexedPathAccessFragment())); + if (ctx.overClause() != null) { + builder.appendExpression(visit(ctx.overClause())); } - return tokens; + return builder; } @Override - public List visitIndexedPathAccessFragment(HqlParser.IndexedPathAccessFragmentContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitWithinGroupClause(HqlParser.WithinGroupClauseContext ctx) { - tokens.add(TOKEN_OPEN_SQUARE_BRACKET); - tokens.addAll(visit(ctx.expression())); - tokens.add(TOKEN_CLOSE_SQUARE_BRACKET); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.generalPathFragment() != null) { + builder.append(QueryTokens.expression(ctx.WITHIN())); + builder.append(QueryTokens.expression(ctx.GROUP())); - tokens.add(TOKEN_DOT); - tokens.addAll(visit(ctx.generalPathFragment())); - } + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.orderByClause())); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitSimplePath(HqlParser.SimplePathContext ctx) { + public QueryTokenStream visitJsonArrayFunction(HqlParser.JsonArrayFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.identifier())); - NOSPACE(tokens); + builder.appendExpression(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); - ctx.simplePathElement().forEach(simplePathElementContext -> { - tokens.addAll(visit(simplePathElementContext)); - NOSPACE(tokens); - }); - SPACE(tokens); + if (ctx.jsonNullClause() != null) { + builder.appendExpression(visit(ctx.jsonNullClause())); + } - return tokens; + return QueryTokenStream.ofFunction(ctx.JSON_ARRAY(), builder); } @Override - public List visitSimplePathElement(HqlParser.SimplePathElementContext ctx) { + public QueryTokenStream visitJsonExistsFunction(HqlParser.JsonExistsFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(TOKEN_DOT); - tokens.addAll(visit(ctx.identifier())); + builder.appendExpression(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - return tokens; - } - - @Override - public List visitCaseList(HqlParser.CaseListContext ctx) { + if (ctx.jsonPassingClause() != null) { + builder.appendExpression(visit(ctx.jsonPassingClause())); + } - if (ctx.simpleCaseExpression() != null) { - return visit(ctx.simpleCaseExpression()); - } else if (ctx.searchedCaseExpression() != null) { - return visit(ctx.searchedCaseExpression()); - } else { - return List.of(); + if (ctx.jsonExistsOnErrorClause() != null) { + builder.appendExpression(visit(ctx.jsonExistsOnErrorClause())); } + + return QueryTokenStream.ofFunction(ctx.JSON_EXISTS(), builder); } @Override - public List visitSimpleCaseExpression(HqlParser.SimpleCaseExpressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.CASE())); - tokens.addAll(visit(ctx.expressionOrPredicate(0))); + public QueryTokenStream visitJsonObjectFunction(HqlParser.JsonObjectFunctionContext ctx) { - ctx.caseWhenExpressionClause().forEach(caseWhenExpressionClauseContext -> { - tokens.addAll(visit(caseWhenExpressionClauseContext)); - }); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.ELSE() != null) { + builder.appendExpression(QueryTokenStream.concat(ctx.jsonObjectFunctionEntry(), this::visit, TOKEN_COMMA)); - tokens.add(new JpaQueryParsingToken(ctx.ELSE())); - tokens.addAll(visit(ctx.expressionOrPredicate(1))); + if (ctx.jsonNullClause() != null) { + builder.appendExpression(visit(ctx.jsonNullClause())); } - tokens.add(new JpaQueryParsingToken(ctx.END())); - - return tokens; + return QueryTokenStream.ofFunction(ctx.JSON_OBJECT(), builder); } @Override - public List visitSearchedCaseExpression(HqlParser.SearchedCaseExpressionContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitJsonQueryFunction(HqlParser.JsonQueryFunctionContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.CASE())); + QueryRendererBuilder builder = QueryRenderer.builder(); - ctx.caseWhenPredicateClause().forEach(caseWhenPredicateClauseContext -> { - tokens.addAll(visit(caseWhenPredicateClauseContext)); - }); + builder.appendExpression(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - if (ctx.ELSE() != null) { + if (ctx.jsonPassingClause() != null) { + builder.appendExpression(visit(ctx.jsonPassingClause())); + } - tokens.add(new JpaQueryParsingToken(ctx.ELSE())); - tokens.addAll(visit(ctx.expressionOrPredicate())); + if (ctx.jsonQueryWrapperClause() != null) { + builder.appendExpression(visit(ctx.jsonQueryWrapperClause())); } - tokens.add(new JpaQueryParsingToken(ctx.END())); + builder.append(QueryTokenStream.concat(ctx.jsonQueryOnErrorOrEmptyClause(), this::visit, TOKEN_SPACE)); - return tokens; + return QueryTokenStream.ofFunction(ctx.JSON_QUERY(), builder); } @Override - public List visitCaseWhenExpressionClause(HqlParser.CaseWhenExpressionClauseContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitJsonValueFunction(HqlParser.JsonValueFunctionContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.WHEN())); - tokens.addAll(visit(ctx.expression())); - tokens.add(new JpaQueryParsingToken(ctx.THEN())); - tokens.addAll(visit(ctx.expressionOrPredicate())); + QueryRendererBuilder builder = QueryRenderer.builder(); - return tokens; - } + builder.append(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - @Override - public List visitCaseWhenPredicateClause(HqlParser.CaseWhenPredicateClauseContext ctx) { + if (ctx.jsonPassingClause() != null) { + builder.appendExpression(visit(ctx.jsonPassingClause())); + } - List tokens = new ArrayList<>(); + if (ctx.jsonValueReturningClause() != null) { + builder.appendExpression(visit(ctx.jsonValueReturningClause())); + } - tokens.add(new JpaQueryParsingToken(ctx.WHEN())); - tokens.addAll(visit(ctx.predicate())); - tokens.add(new JpaQueryParsingToken(ctx.THEN())); - tokens.addAll(visit(ctx.expressionOrPredicate())); + builder.append(QueryTokenStream.concat(ctx.jsonValueOnErrorOrEmptyClause(), this::visit, TOKEN_SPACE)); - return tokens; + return QueryTokenStream.ofFunction(ctx.JSON_VALUE(), builder); } @Override - public List visitGenericFunction(HqlParser.GenericFunctionContext ctx) { + public QueryTokenStream visitJsonArrayAggFunction(HqlParser.JsonArrayAggFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.functionName())); - NOSPACE(tokens); - tokens.add(TOKEN_OPEN_PAREN); + builder.appendExpression(visit(ctx.expressionOrPredicate())); - if (ctx.functionArguments() != null) { - tokens.addAll(visit(ctx.functionArguments())); - } else if (ctx.ASTERISK() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ASTERISK())); + if (ctx.jsonNullClause() != null) { + builder.appendExpression(visit(ctx.jsonNullClause())); } - tokens.add(TOKEN_CLOSE_PAREN); - - if (ctx.pathContinutation() != null) { - NOSPACE(tokens); - tokens.addAll(visit(ctx.pathContinutation())); + if (ctx.orderByClause() != null) { + builder.appendExpression(visit(ctx.orderByClause())); } - if (ctx.filterClause() != null) { - tokens.addAll(visit(ctx.filterClause())); - } + QueryTokenStream function = QueryTokenStream.ofFunction(ctx.JSON_ARRAYAGG(), builder); - if (ctx.withinGroup() != null) { - tokens.addAll(visit(ctx.withinGroup())); + if (ctx.filterClause() == null) { + return function; } - if (ctx.overClause() != null) { - tokens.addAll(visit(ctx.overClause())); - } + QueryRendererBuilder functionWithFilter = QueryRenderer.builder(); + functionWithFilter.appendExpression(function); + functionWithFilter.appendExpression(visit(ctx.filterClause())); - return tokens; + return functionWithFilter.build(); } @Override - public List visitFunctionWithSubquery(HqlParser.FunctionWithSubqueryContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.functionName())); - NOSPACE(tokens); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitCastFunctionInvocation(HqlParser.CastFunctionInvocationContext ctx) { - return visit(ctx.castFunction()); - } - - @Override - public List visitExtractFunctionInvocation(HqlParser.ExtractFunctionInvocationContext ctx) { - return visit(ctx.extractFunction()); - } - - @Override - public List visitTrimFunctionInvocation(HqlParser.TrimFunctionInvocationContext ctx) { - return visit(ctx.trimFunction()); - } + public QueryTokenStream visitJsonObjectAggFunction(HqlParser.JsonObjectAggFunctionContext ctx) { - @Override - public List visitEveryFunctionInvocation(HqlParser.EveryFunctionInvocationContext ctx) { - return visit(ctx.everyFunction()); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - @Override - public List visitAnyFunctionInvocation(HqlParser.AnyFunctionInvocationContext ctx) { - return visit(ctx.anyFunction()); - } + if (ctx.KEY() != null) { + builder.append(QueryTokens.expression(ctx.KEY())); + } - @Override - public List visitTreatedPathInvocation(HqlParser.TreatedPathInvocationContext ctx) { - return visit(ctx.treatedPath()); - } + builder.appendExpression(visit(ctx.expressionOrPredicate(0))); - @Override - public List visitFunctionArguments(HqlParser.FunctionArgumentsContext ctx) { + if (ctx.VALUE() != null) { + builder.append(QueryTokens.expression(ctx.VALUE())); + } else { + builder.append(TOKEN_COLON); + } - List tokens = new ArrayList<>(); + builder.appendExpression(visit(ctx.expressionOrPredicate(1))); - if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); + if (ctx.jsonNullClause() != null) { + builder.appendExpression(visit(ctx.jsonNullClause())); } - ctx.expressionOrPredicate().forEach(expressionOrPredicateContext -> { - tokens.addAll(visit(expressionOrPredicateContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + if (ctx.jsonUniqueKeysClause() != null) { + builder.appendExpression(visit(ctx.jsonUniqueKeysClause())); + } - return tokens; - } + QueryTokenStream function = QueryTokenStream.ofFunction(ctx.JSON_OBJECTAGG(), builder); - @Override - public List visitFilterClause(HqlParser.FilterClauseContext ctx) { - - List tokens = new ArrayList<>(); + if (ctx.filterClause() == null) { + return function; + } - tokens.add(new JpaQueryParsingToken(ctx.FILTER())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.whereClause())); - tokens.add(TOKEN_CLOSE_PAREN); + QueryRendererBuilder functionWithFilter = QueryRenderer.builder(); + functionWithFilter.appendExpression(function); + functionWithFilter.appendExpression(visit(ctx.filterClause())); - return tokens; + return functionWithFilter.build(); } @Override - public List visitWithinGroup(HqlParser.WithinGroupContext ctx) { + public QueryTokenStream visitJsonPassingClause(HqlParser.JsonPassingClauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.WITHIN())); - tokens.add(new JpaQueryParsingToken(ctx.GROUP())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.orderByClause())); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.expression(ctx.PASSING())); + builder.append(QueryTokenStream.concat(ctx.aliasedExpressionOrPredicate(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitOverClause(HqlParser.OverClauseContext ctx) { + public QueryTokenStream visitJsonTableFunction(HqlParser.JsonTableFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.OVER())); - tokens.add(TOKEN_OPEN_PAREN); + builder.appendExpression(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - if (ctx.partitionClause() != null) { - tokens.addAll(visit(ctx.partitionClause())); + if (ctx.jsonPassingClause() != null) { + builder.appendExpression(visit(ctx.jsonPassingClause())); } - if (ctx.orderByClause() != null) { - tokens.addAll(visit(ctx.orderByClause())); - SPACE(tokens); - } + builder.appendExpression(visit(ctx.jsonTableColumnsClause())); - if (ctx.frameClause() != null) { - tokens.addAll(visit(ctx.frameClause())); + if (ctx.jsonTableErrorClause() != null) { + builder.appendExpression(visit(ctx.jsonTableErrorClause())); } - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; + return QueryTokenStream.ofFunction(ctx.JSON_TABLE(), builder); } @Override - public List visitPartitionClause(HqlParser.PartitionClauseContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.PARTITION())); - tokens.add(new JpaQueryParsingToken(ctx.BY())); - - ctx.expression().forEach(expressionContext -> { - tokens.addAll(visit(expressionContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); - - return tokens; + public QueryTokenStream visitJsonTableColumnsClause(HqlParser.JsonTableColumnsClauseContext ctx) { + return QueryTokenStream.ofFunction(ctx.COLUMNS(), visit(ctx.jsonTableColumns())); } @Override - public List visitFrameClause(HqlParser.FrameClauseContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.RANGE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.RANGE())); - } else if (ctx.ROWS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ROWS())); - } else if (ctx.GROUPS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.GROUPS())); - } + public QueryTokenStream visitJsonTableColumns(HqlParser.JsonTableColumnsContext ctx) { + return QueryTokenStream.concat(ctx.jsonTableColumn(), this::visit, TOKEN_COMMA); + } - if (ctx.BETWEEN() != null) { - tokens.add(new JpaQueryParsingToken(ctx.BETWEEN())); - } + @Override + public QueryTokenStream visitXmlElementFunction(HqlParser.XmlElementFunctionContext ctx) { - tokens.addAll(visit(ctx.frameStart())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.AND() != null) { + builder.append(QueryTokens.expression(ctx.NAME())); + builder.append(visit(ctx.identifier())); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.frameEnd())); + if (ctx.xmlAttributesFunction() != null) { + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.xmlAttributesFunction())); } - if (ctx.frameExclusion() != null) { - tokens.addAll(visit(ctx.frameExclusion())); + if (!CollectionUtils.isEmpty(ctx.expressionOrPredicate())) { + builder.append(TOKEN_COMMA); + builder.append(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); } - return tokens; + return QueryTokenStream.ofFunction(ctx.XMLELEMENT(), builder); } @Override - public List visitUnboundedPrecedingFrameStart( - HqlParser.UnboundedPrecedingFrameStartContext ctx) { + public QueryTokenStream visitXmlAttributesFunction(HqlParser.XmlAttributesFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.UNBOUNDED())); - tokens.add(new JpaQueryParsingToken(ctx.PRECEDING())); + builder.appendExpression(QueryTokenStream.concat(ctx.aliasedExpressionOrPredicate(), this::visit, TOKEN_COMMA)); - return tokens; + return QueryTokenStream.ofFunction(ctx.XMLATTRIBUTES(), builder); } @Override - public List visitExpressionPrecedingFrameStart( - HqlParser.ExpressionPrecedingFrameStartContext ctx) { + public QueryTokenStream visitXmlForestFunction(HqlParser.XmlForestFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.expression())); - tokens.add(new JpaQueryParsingToken(ctx.PRECEDING())); + builder.appendExpression( + QueryTokenStream.concat(ctx.potentiallyAliasedExpressionOrPredicate(), this::visit, TOKEN_COMMA)); - return tokens; + return QueryTokenStream.ofFunction(ctx.XMLFOREST(), builder); } @Override - public List visitCurrentRowFrameStart(HqlParser.CurrentRowFrameStartContext ctx) { + public QueryTokenStream visitXmlPiFunction(HqlParser.XmlPiFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.CURRENT())); - tokens.add(new JpaQueryParsingToken(ctx.ROW())); + builder.append(QueryTokens.expression(ctx.NAME())); + builder.append(visit(ctx.identifier())); - return tokens; + if (ctx.expression() != null) { + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.expression())); + } + + return QueryTokenStream.ofFunction(ctx.XMLPI(), builder); } @Override - public List visitExpressionFollowingFrameStart( - HqlParser.ExpressionFollowingFrameStartContext ctx) { + public QueryTokenStream visitXmlQueryFunction(HqlParser.XmlQueryFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.expression())); - tokens.add(new JpaQueryParsingToken(ctx.FOLLOWING())); + builder.appendExpression(visit(ctx.expression(0))); + builder.append(QueryTokens.expression(ctx.PASSING())); + builder.appendExpression(visit(ctx.expression(1))); - return tokens; + return QueryTokenStream.ofFunction(ctx.XMLQUERY(), builder); } @Override - public List visitCurrentRowFrameExclusion(HqlParser.CurrentRowFrameExclusionContext ctx) { + public QueryTokenStream visitXmlExistsFunction(HqlParser.XmlExistsFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.EXCLUDE())); - tokens.add(new JpaQueryParsingToken(ctx.CURRENT())); - tokens.add(new JpaQueryParsingToken(ctx.ROW())); + builder.appendExpression(visit(ctx.expression(0))); + builder.append(QueryTokens.expression(ctx.PASSING())); + builder.appendExpression(visit(ctx.expression(1))); - return tokens; + return QueryTokenStream.ofFunction(ctx.XMLEXISTS(), builder); } @Override - public List visitGroupFrameExclusion(HqlParser.GroupFrameExclusionContext ctx) { + public QueryTokenStream visitXmlAggFunction(HqlParser.XmlAggFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder args = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.EXCLUDE())); - tokens.add(new JpaQueryParsingToken(ctx.GROUP())); + args.appendExpression(visit(ctx.expression())); + if (ctx.orderByClause() != null) { + args.appendExpression(visit(ctx.orderByClause())); + } - return tokens; - } + QueryTokenStream function = QueryTokenStream.ofFunction(ctx.XMLAGG(), args); - @Override - public List visitTiesFrameExclusion(HqlParser.TiesFrameExclusionContext ctx) { + if (ctx.filterClause() == null && ctx.overClause() == null) { + return function; + } + + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.appendExpression(function); - List tokens = new ArrayList<>(); + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); + } - tokens.add(new JpaQueryParsingToken(ctx.EXCLUDE())); - tokens.add(new JpaQueryParsingToken(ctx.TIES())); + if (ctx.overClause() != null) { + builder.appendExpression(visit(ctx.overClause())); + } - return tokens; + return builder; } @Override - public List visitNoOthersFrameExclusion(HqlParser.NoOthersFrameExclusionContext ctx) { + public QueryTokenStream visitXmlTableFunction(HqlParser.XmlTableFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder args = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.EXCLUDE())); - tokens.add(new JpaQueryParsingToken(ctx.NO())); - tokens.add(new JpaQueryParsingToken(ctx.OTHERS())); + args.appendExpression(visit(ctx.expression(0))); + args.append(QueryTokens.expression(ctx.PASSING())); + args.appendExpression(visit(ctx.expression(1))); + args.appendExpression(visit(ctx.xmlTableColumnsClause())); - return tokens; + return QueryTokenStream.ofFunction(ctx.XMLTABLE(), args); } @Override - public List visitExpressionPrecedingFrameEnd(HqlParser.ExpressionPrecedingFrameEndContext ctx) { + public QueryTokenStream visitXmlTableColumnsClause(HqlParser.XmlTableColumnsClauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.expression())); - tokens.add(new JpaQueryParsingToken(ctx.PRECEDING())); + builder.append(QueryTokens.expression(ctx.COLUMNS())); + builder.append(QueryTokenStream.concat(ctx.xmlTableColumn(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitCurrentRowFrameEnd(HqlParser.CurrentRowFrameEndContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.CURRENT())); - tokens.add(new JpaQueryParsingToken(ctx.ROW())); - - return tokens; + public QueryTokenStream visitPath(HqlParser.PathContext ctx) { + return QueryTokenStream.concat(ctx.children, this::visit, EMPTY_TOKEN); } @Override - public List visitExpressionFollowingFrameEnd(HqlParser.ExpressionFollowingFrameEndContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.expression())); - tokens.add(new JpaQueryParsingToken(ctx.FOLLOWING())); - - return tokens; + public QueryTokenStream visitGeneralPathFragment(HqlParser.GeneralPathFragmentContext ctx) { + return QueryTokenStream.concat(ctx.children, this::visit, EMPTY_TOKEN); } @Override - public List visitUnboundedFollowingFrameEnd(HqlParser.UnboundedFollowingFrameEndContext ctx) { + public QueryTokenStream visitIndexedPathAccessFragment(HqlParser.IndexedPathAccessFragmentContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.UNBOUNDED())); - tokens.add(new JpaQueryParsingToken(ctx.FOLLOWING())); + builder.append(TOKEN_OPEN_SQUARE_BRACKET); + builder.appendInline(visit(ctx.expression())); + builder.append(TOKEN_CLOSE_SQUARE_BRACKET); - return tokens; - } - - @Override - public List visitCastFunction(HqlParser.CastFunctionContext ctx) { - - List tokens = new ArrayList<>(); + if (ctx.generalPathFragment() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CAST(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.expression())); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.castTarget())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_DOT); + builder.append(visit(ctx.generalPathFragment())); + } - return tokens; + return builder; } @Override - public List visitCastTarget(HqlParser.CastTargetContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.castTargetType())); + public QueryTokenStream visitSimplePath(HqlParser.SimplePathContext ctx) { - if (ctx.INTEGER_LITERAL() != null && !ctx.INTEGER_LITERAL().isEmpty()) { + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.identifier())); - ctx.INTEGER_LITERAL().forEach(terminalNode -> { - - tokens.add(new JpaQueryParsingToken(terminalNode)); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - NOSPACE(tokens); - - tokens.add(TOKEN_CLOSE_PAREN); + if (!ctx.simplePathElement().isEmpty()) { + builder.append(TOKEN_DOT); } - return tokens; + builder.append(QueryTokenStream.concat(ctx.simplePathElement(), this::visit, TOKEN_DOT)); + + return builder; } @Override - public List visitCastTargetType(HqlParser.CastTargetTypeContext ctx) { - return List.of(new JpaQueryParsingToken(ctx.fullTargetName)); + public QueryTokenStream visitSimplePathElement(HqlParser.SimplePathElementContext ctx) { + return visit(ctx.identifier()); } @Override - public List visitExtractFunction(HqlParser.ExtractFunctionContext ctx) { + public QueryTokenStream visitGenericFunction(HqlParser.GenericFunctionContext ctx) { - List tokens = new ArrayList<>(); - - if (ctx.EXTRACT() != null) { + QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.EXTRACT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - tokens.addAll(visit(ctx.expression(1))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.dateTimeFunction() != null) { + nested.append(visit(ctx.genericFunctionName())); + nested.append(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.dateTimeFunction())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.expression(0))); - tokens.add(TOKEN_CLOSE_PAREN); + if (ctx.genericFunctionArguments() != null) { + nested.appendInline(visit(ctx.genericFunctionArguments())); + } else if (ctx.ASTERISK() != null) { + nested.append(QueryTokens.token(ctx.ASTERISK())); } - return tokens; - } - - @Override - public List visitTrimFunction(HqlParser.TrimFunctionContext ctx) { - - List tokens = new ArrayList<>(); + nested.append(TOKEN_CLOSE_PAREN); + builder.append(nested); - tokens.add(new JpaQueryParsingToken(ctx.TRIM())); - tokens.add(TOKEN_OPEN_PAREN); + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); + } - if (ctx.LEADING() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LEADING())); - } else if (ctx.TRAILING() != null) { - tokens.add(new JpaQueryParsingToken(ctx.TRAILING())); - } else if (ctx.BOTH() != null) { - tokens.add(new JpaQueryParsingToken(ctx.BOTH())); + if (ctx.nthSideClause() != null) { + builder.appendExpression(visit(ctx.nthSideClause())); } - if (ctx.stringLiteral() != null) { - tokens.addAll(visit(ctx.stringLiteral())); + if (ctx.nullsClause() != null) { + builder.appendExpression(visit(ctx.nullsClause())); } - if (ctx.FROM() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FROM())); + if (ctx.withinGroupClause() != null) { + builder.appendExpression(visit(ctx.withinGroupClause())); } - tokens.addAll(visit(ctx.expression())); - tokens.add(TOKEN_CLOSE_PAREN); + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); + } - return tokens; - } + if (ctx.overClause() != null) { + builder.appendExpression(visit(ctx.overClause())); + } - @Override - public List visitDateTimeFunction(HqlParser.DateTimeFunctionContext ctx) { - return List.of(new JpaQueryParsingToken(ctx.d)); + return builder; } @Override - public List visitEveryFunction(HqlParser.EveryFunctionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.every)); - - if (ctx.ELEMENTS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ELEMENTS())); - } else if (ctx.INDICES() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INDICES())); - } - - tokens.add(TOKEN_OPEN_PAREN); + public QueryTokenStream visitFilterClause(HqlParser.FilterClauseContext ctx) { - if (ctx.predicate() != null) { - tokens.addAll(visit(ctx.predicate())); - } else if (ctx.subquery() != null) { - tokens.addAll(visit(ctx.subquery())); - } else if (ctx.simplePath() != null) { - tokens.addAll(visit(ctx.simplePath())); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.expression(ctx.FILTER())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.whereClause())); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitAnyFunction(HqlParser.AnyFunctionContext ctx) { + public QueryTokenStream visitOverClause(HqlParser.OverClauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(QueryTokens.expression(ctx.OVER())); - tokens.add(new JpaQueryParsingToken(ctx.any)); + QueryRendererBuilder nested = QueryRenderer.builder(); - if (ctx.ELEMENTS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ELEMENTS())); - } else if (ctx.INDICES() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INDICES())); + List trees = new ArrayList<>(); + + if (ctx.partitionClause() != null) { + trees.add(ctx.partitionClause()); } - tokens.add(TOKEN_OPEN_PAREN); + if (ctx.orderByClause() != null) { + trees.add(ctx.orderByClause()); + } - if (ctx.predicate() != null) { - tokens.addAll(visit(ctx.predicate())); - } else if (ctx.subquery() != null) { - tokens.addAll(visit(ctx.subquery())); - } else if (ctx.simplePath() != null) { - tokens.addAll(visit(ctx.simplePath())); + if (ctx.frameClause() != null) { + trees.add(ctx.frameClause()); } - tokens.add(TOKEN_CLOSE_PAREN); + nested.appendInline(QueryTokenStream.concat(trees, this::visit, TOKEN_SPACE)); + builder.appendInline(QueryTokenStream.group(nested)); - return tokens; + return builder; } @Override - public List visitTreatedPath(HqlParser.TreatedPathContext ctx) { + public QueryTokenStream visitPartitionClause(HqlParser.PartitionClauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.TREAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.path())); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.simplePath())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.expression(ctx.PARTITION())); + builder.append(QueryTokens.expression(ctx.BY())); - if (ctx.pathContinutation() != null) { - NOSPACE(tokens); - tokens.addAll(visit(ctx.pathContinutation())); - } + builder.append(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitPathContinutation(HqlParser.PathContinutationContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitCastFunction(HqlParser.CastFunctionContext ctx) { - tokens.add(TOKEN_DOT); - tokens.addAll(visit(ctx.simplePath())); + QueryRendererBuilder nested = QueryRenderer.builder(); + nested.appendExpression(visit(ctx.expression())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.castTarget())); - return tokens; + return QueryTokenStream.ofFunction(ctx.CAST(), nested); } @Override - public List visitNullExpressionPredicate(HqlParser.NullExpressionPredicateContext ctx) { - return visit(ctx.dealingWithNullExpression()); - } + public QueryTokenStream visitCastTarget(HqlParser.CastTargetContext ctx) { - @Override - public List visitBetweenPredicate(HqlParser.BetweenPredicateContext ctx) { - return visit(ctx.betweenExpression()); - } + List literals = ctx.INTEGER_LITERAL(); - @Override - public List visitOrPredicate(HqlParser.OrPredicateContext ctx) { + if (!CollectionUtils.isEmpty(literals)) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(visit(ctx.castTargetType())); + builder.append(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.predicate(0))); - tokens.add(new JpaQueryParsingToken(ctx.OR())); - tokens.addAll(visit(ctx.predicate(1))); + QueryRendererBuilder args = QueryRenderer.builder(); + for (int i = 0; i < literals.size(); i++) { + if (i > 0) { + args.append(TOKEN_COMMA); + } + args.append(QueryTokens.token(literals.get(i))); + } - return tokens; - } + builder.appendInline(args.build()); + builder.append(TOKEN_CLOSE_PAREN); - @Override - public List visitRelationalPredicate(HqlParser.RelationalPredicateContext ctx) { - return visit(ctx.relationalExpression()); - } + return builder.build(); + } - @Override - public List visitExistsPredicate(HqlParser.ExistsPredicateContext ctx) { - return visit(ctx.existsExpression()); + return visit(ctx.castTargetType()); } @Override - public List visitCollectionPredicate(HqlParser.CollectionPredicateContext ctx) { - return visit(ctx.collectionExpression()); + public QueryTokenStream visitCastTargetType(HqlParser.CastTargetTypeContext ctx) { + return QueryTokens.token(ctx.fullTargetName); } @Override - public List visitAndPredicate(HqlParser.AndPredicateContext ctx) { + public QueryTokenStream visitExtractFunction(HqlParser.ExtractFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.predicate(0))); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.predicate(1))); - - return tokens; - } + if (ctx.EXTRACT() != null) { - @Override - public List visitGroupedPredicate(HqlParser.GroupedPredicateContext ctx) { + builder.append(QueryTokens.token(ctx.EXTRACT())); + builder.append(TOKEN_OPEN_PAREN); - List tokens = new ArrayList<>(); + QueryRendererBuilder nested = QueryRenderer.builder(); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.predicate())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + nested.appendExpression(visit(ctx.extractField())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.expression())); - return tokens; - } + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); + } else if (ctx.datetimeField() != null) { - @Override - public List visitLikePredicate(HqlParser.LikePredicateContext ctx) { - return visit(ctx.stringPatternMatching()); - } + builder.append(visit(ctx.datetimeField())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.expression())); + builder.append(TOKEN_CLOSE_PAREN); + } - @Override - public List visitInPredicate(HqlParser.InPredicateContext ctx) { - return visit(ctx.inExpression()); + return builder; } @Override - public List visitNotPredicate(HqlParser.NotPredicateContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitTrimFunction(HqlParser.TrimFunctionContext ctx) { - tokens.add(TOKEN_NOT); - tokens.addAll(visit(ctx.predicate())); + QueryRendererBuilder builder = QueryRenderer.builder(); - return tokens; - } + if (ctx.trimSpecification() != null) { + builder.appendExpression(visit(ctx.trimSpecification())); + } - @Override - public List visitExpressionPredicate(HqlParser.ExpressionPredicateContext ctx) { - return visit(ctx.expression()); - } + if (ctx.trimCharacter() != null) { + builder.appendExpression(visit(ctx.trimCharacter())); + } - @Override - public List visitExpressionOrPredicate(HqlParser.ExpressionOrPredicateContext ctx) { + if (ctx.FROM() != null) { + builder.append(QueryTokens.expression(ctx.FROM())); + } if (ctx.expression() != null) { - return visit(ctx.expression()); - } else if (ctx.predicate() != null) { - return visit(ctx.predicate()); - } else { - return List.of(); + builder.append(visit(ctx.expression())); } + + return QueryTokenStream.ofFunction(ctx.TRIM(), builder); } @Override - public List visitRelationalExpression(HqlParser.RelationalExpressionContext ctx) { + public QueryTokenStream visitEveryFunction(HqlParser.EveryFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - tokens.addAll(visit(ctx.expression(1))); + builder.appendExpression(visit(ctx.everyAllQuantifier())); - return tokens; - } + if (ctx.predicate() != null) { + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.predicate())); + builder.append(TOKEN_CLOSE_PAREN); - @Override - public List visitBetweenExpression(HqlParser.BetweenExpressionContext ctx) { + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); + } - List tokens = new ArrayList<>(); + if (ctx.overClause() != null) { + builder.appendExpression(visit(ctx.overClause())); + } + } else if (ctx.subquery() != null) { + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.subquery())); + builder.append(TOKEN_CLOSE_PAREN); + } else { - tokens.addAll(visit(ctx.expression(0))); + builder.append(visit(ctx.collectionQuantifier())); - if (ctx.NOT() != null) { - tokens.add(TOKEN_NOT); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.simplePath())); + builder.append(TOKEN_CLOSE_PAREN); } - tokens.add(new JpaQueryParsingToken(ctx.BETWEEN())); - tokens.addAll(visit(ctx.expression(1))); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.expression(2))); - - return tokens; + return builder; } @Override - public List visitDealingWithNullExpression(HqlParser.DealingWithNullExpressionContext ctx) { + public QueryTokenStream visitAnyFunction(HqlParser.AnyFunctionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.IS())); + builder.appendExpression(visit(ctx.anySomeQuantifier())); - if (ctx.NOT() != null) { - tokens.add(TOKEN_NOT); - } + if (ctx.predicate() != null) { + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.predicate())); + builder.append(TOKEN_CLOSE_PAREN); - if (ctx.NULL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NULL())); - } else if (ctx.DISTINCT() != null) { + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); + } - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - tokens.addAll(visit(ctx.expression(1))); + if (ctx.overClause() != null) { + builder.appendExpression(visit(ctx.overClause())); + } + } else if (ctx.subquery() != null) { + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.subquery())); + builder.append(TOKEN_CLOSE_PAREN); + } else { + + builder.append(visit(ctx.collectionQuantifier())); + + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.simplePath())); + builder.append(TOKEN_CLOSE_PAREN); } - return tokens; + return builder; } @Override - public List visitStringPatternMatching(HqlParser.StringPatternMatchingContext ctx) { + public QueryTokenStream visitTreatedNavigablePath(HqlParser.TreatedNavigablePathContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - tokens.addAll(visit(ctx.expression(0))); + nested.appendExpression(visit(ctx.path())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.append(visit(ctx.simplePath())); - if (ctx.NOT() != null) { - tokens.add(TOKEN_NOT); - } + builder.append(QueryTokenStream.ofFunction(ctx.TREAT(), nested)); - if (ctx.LIKE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LIKE())); - } else if (ctx.ILIKE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ILIKE())); + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); } - tokens.addAll(visit(ctx.expression(1))); + return builder; + } - if (ctx.ESCAPE() != null) { + @Override + public QueryTokenStream visitCollectionValueNavigablePath(HqlParser.CollectionValueNavigablePathContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.ESCAPE())); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.stringLiteral() != null) { - tokens.addAll(visit(ctx.stringLiteral())); - } else if (ctx.parameter() != null) { - tokens.addAll(visit(ctx.parameter())); - } + builder.append(visit(ctx.elementValueQuantifier())); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.path())); + builder.append(TOKEN_CLOSE_PAREN); + + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); } - return tokens; + return builder; } @Override - public List visitInExpression(HqlParser.InExpressionContext ctx) { + public QueryTokenStream visitMapKeyNavigablePath(HqlParser.MapKeyNavigablePathContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.expression())); + builder.append(visit(ctx.indexKeyQuantifier())); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.path())); + builder.append(TOKEN_CLOSE_PAREN); - if (ctx.NOT() != null) { - tokens.add(TOKEN_NOT); + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); } - tokens.add(new JpaQueryParsingToken(ctx.IN())); - tokens.addAll(visit(ctx.inList())); + return builder; + } + + @Override + public QueryTokenStream visitToOneFkReference(HqlParser.ToOneFkReferenceContext ctx) { + return QueryTokenStream.ofFunction(ctx.FK(), visit(ctx.path())); + } - return tokens; + @Override + public QueryTokenStream visitGroupedPredicate(HqlParser.GroupedPredicateContext ctx) { + return QueryTokenStream.group(visit(ctx.predicate())); } @Override - public List visitInList(HqlParser.InListContext ctx) { + public QueryTokenStream visitInList(HqlParser.InListContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.simplePath() != null) { if (ctx.ELEMENTS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ELEMENTS())); + builder.append(QueryTokens.token(ctx.ELEMENTS())); } else if (ctx.INDICES() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INDICES())); + builder.append(QueryTokens.token(ctx.INDICES())); } - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.simplePath())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.simplePath())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.subquery() != null) { - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.subquery())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.parameter() != null) { - tokens.addAll(visit(ctx.parameter())); + builder.append(visit(ctx.parameter())); } else if (ctx.expressionOrPredicate() != null) { - tokens.add(TOKEN_OPEN_PAREN); - - ctx.expressionOrPredicate().forEach(expressionOrPredicateContext -> { - tokens.addAll(visit(expressionOrPredicateContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_PAREN); } - return tokens; + return builder; } @Override - public List visitExistsExpression(HqlParser.ExistsExpressionContext ctx) { + public QueryTokenStream visitExistsExpression(HqlParser.ExistsExpressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.simplePath() != null) { - tokens.add(new JpaQueryParsingToken(ctx.EXISTS())); + builder.append(QueryTokens.expression(ctx.EXISTS())); if (ctx.ELEMENTS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ELEMENTS())); + builder.append(QueryTokens.token(ctx.ELEMENTS())); } else if (ctx.INDICES() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INDICES())); + builder.append(QueryTokens.token(ctx.INDICES())); } - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.simplePath())); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.simplePath())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.expression() != null) { - tokens.add(new JpaQueryParsingToken(ctx.EXISTS())); - tokens.addAll(visit(ctx.expression())); - } - - return tokens; - } - - @Override - public List visitCollectionExpression(HqlParser.CollectionExpressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.expression())); - - if (ctx.IS() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.IS())); - - if (ctx.NOT() != null) { - tokens.add(TOKEN_NOT); - } - - tokens.add(new JpaQueryParsingToken(ctx.EMPTY())); - } else if (ctx.MEMBER() != null) { - - if (ctx.NOT() != null) { - tokens.add(TOKEN_NOT); - } - - tokens.add(new JpaQueryParsingToken(ctx.MEMBER())); - tokens.add(new JpaQueryParsingToken(ctx.OF())); - tokens.addAll(visit(ctx.path())); - } - - return tokens; - } - - @Override - public List visitInstantiationTarget(HqlParser.InstantiationTargetContext ctx) { - - if (ctx.LIST() != null) { - return List.of(new JpaQueryParsingToken(ctx.LIST())); - } else if (ctx.MAP() != null) { - return List.of(new JpaQueryParsingToken(ctx.MAP())); - } else if (ctx.simplePath() != null) { - - List tokens = visit(ctx.simplePath()); - NOSPACE(tokens); - return tokens; - } else { - return List.of(); - } - } - - @Override - public List visitInstantiationArguments(HqlParser.InstantiationArgumentsContext ctx) { - - List tokens = new ArrayList<>(); - - ctx.instantiationArgument().forEach(instantiationArgumentContext -> { - tokens.addAll(visit(instantiationArgumentContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - - return tokens; - } - - @Override - public List visitInstantiationArgument(HqlParser.InstantiationArgumentContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.expressionOrPredicate() != null) { - tokens.addAll(visit(ctx.expressionOrPredicate())); - } else if (ctx.instantiation() != null) { - tokens.addAll(visit(ctx.instantiation())); - } - - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); + builder.append(QueryTokens.expression(ctx.EXISTS())); + builder.appendExpression(visit(ctx.expression())); } - return tokens; - } - - @Override - public List visitParameterOrIntegerLiteral(HqlParser.ParameterOrIntegerLiteralContext ctx) { - - if (ctx.parameter() != null) { - return visit(ctx.parameter()); - } else if (ctx.INTEGER_LITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.INTEGER_LITERAL())); - } else { - return List.of(); - } + return builder; } @Override - public List visitParameterOrNumberLiteral(HqlParser.ParameterOrNumberLiteralContext ctx) { - - if (ctx.parameter() != null) { - return visit(ctx.parameter()); - } else if (ctx.numericLiteral() != null) { - return visit(ctx.numericLiteral()); - } else { - return List.of(); - } - } - - @Override - public List visitVariable(HqlParser.VariableContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.identifier() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.identifier())); - } else if (ctx.reservedWord() != null) { - tokens.addAll(visit(ctx.reservedWord())); - } - - return tokens; + public QueryTokenStream visitInstantiationArguments(HqlParser.InstantiationArgumentsContext ctx) { + return QueryTokenStream.concat(ctx.instantiationArgument(), this::visit, TOKEN_COMMA); } @Override - public List visitParameter(HqlParser.ParameterContext ctx) { + public QueryTokenStream visitParameter(HqlParser.ParameterContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.prefix.getText().equals(":")) { - tokens.add(TOKEN_COLON); - tokens.addAll(visit(ctx.identifier())); + builder.append(TOKEN_COLON); + builder.append(visit(ctx.identifier())); } else if (ctx.prefix.getText().equals("?")) { - tokens.add(TOKEN_QUESTION_MARK); + builder.append(TOKEN_QUESTION_MARK); if (ctx.INTEGER_LITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INTEGER_LITERAL())); + builder.append(QueryTokens.token(ctx.INTEGER_LITERAL())); } } - return tokens; + return builder; } @Override - public List visitEntityName(HqlParser.EntityNameContext ctx) { - - List tokens = new ArrayList<>(); - - ctx.identifier().forEach(identifierContext -> { - tokens.addAll(visit(identifierContext)); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - }); - CLIP(tokens); - SPACE(tokens); - - return tokens; + public QueryTokenStream visitEntityName(HqlParser.EntityNameContext ctx) { + return QueryTokenStream.concat(ctx.identifier(), this::visit, TOKEN_DOT); } @Override - public List visitIdentifier(HqlParser.IdentifierContext ctx) { + public QueryTokenStream visitChildren(RuleNode node) { - if (ctx.reservedWord() != null) { - return visit(ctx.reservedWord()); - } else { - return List.of(); - } - } + int childCount = node.getChildCount(); - @Override - public List visitCharacter(HqlParser.CharacterContext ctx) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); - } - - @Override - public List visitFunctionName(HqlParser.FunctionNameContext ctx) { - - List tokens = new ArrayList<>(); + if (childCount == 1 && node.getChild(0) instanceof RuleContext t) { + return visit(t); + } - ctx.reservedWord().forEach(reservedWordContext -> { - tokens.addAll(visit(reservedWordContext)); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - }); - CLIP(tokens); + if (childCount == 1 && node.getChild(0) instanceof TerminalNode t) { + return QueryTokens.token(t); + } - return tokens; + return QueryTokenStream.concatExpressions(node, this::visit); } - @Override - public List visitReservedWord(HqlParser.ReservedWordContext ctx) { - - if (ctx.IDENTIFICATION_VARIABLE() != null) { - return List.of(new JpaQueryParsingToken(ctx.IDENTIFICATION_VARIABLE())); - } else { - return List.of(new JpaQueryParsingToken(ctx.f)); - } - } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryTransformer.java deleted file mode 100644 index f1e18f3970..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryTransformer.java +++ /dev/null @@ -1,401 +0,0 @@ -/* - * Copyright 2022-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 static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import org.antlr.v4.runtime.ParserRuleContext; -import org.springframework.data.domain.Sort; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; - -/** - * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed HQL query. - * - * @author Greg Turnquist - * @author Christoph Strobl - * @since 3.1 - */ -class HqlQueryTransformer extends HqlQueryRenderer { - - // TODO: Separate input from result parameters, encapsulation... - - private final Sort sort; - private final boolean countQuery; - - private final @Nullable String countProjection; - - private @Nullable String primaryFromAlias = null; - - private List projection = Collections.emptyList(); - private boolean projectionProcessed; - - private boolean hasConstructorExpression = false; - - private JpaQueryTransformerSupport transformerSupport; - - HqlQueryTransformer() { - this(Sort.unsorted(), false, null); - } - - HqlQueryTransformer(Sort sort) { - this(sort, false, null); - } - - HqlQueryTransformer(boolean countQuery, @Nullable String countProjection) { - this(Sort.unsorted(), countQuery, countProjection); - } - - private HqlQueryTransformer(Sort sort, boolean countQuery, @Nullable String countProjection) { - - Assert.notNull(sort, "Sort must not be null"); - - this.sort = sort; - this.countQuery = countQuery; - this.countProjection = countProjection; - this.transformerSupport = new JpaQueryTransformerSupport(); - } - - @Nullable - public String getAlias() { - return this.primaryFromAlias; - } - - public List getProjection() { - return this.projection; - } - - public boolean hasConstructorExpression() { - return this.hasConstructorExpression; - } - - /** - * Is this select clause a {@literal subquery}? - * - * @return boolean - */ - private static boolean isSubquery(ParserRuleContext ctx) { - - if (ctx instanceof HqlParser.SubqueryContext) { - return true; - } else if (ctx instanceof HqlParser.SelectStatementContext) { - return false; - } else if (ctx instanceof HqlParser.InsertStatementContext) { - return false; - } else { - return isSubquery(ctx.getParent()); - } - } - - @Override - public List visitOrderedQuery(HqlParser.OrderedQueryContext ctx) { - - List tokens = newArrayList(); - - if (ctx.query() != null) { - tokens.addAll(visit(ctx.query())); - } else if (ctx.queryExpression() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.queryExpression())); - tokens.add(TOKEN_CLOSE_PAREN); - } - - if (!countQuery && !isSubquery(ctx)) { - - if (ctx.queryOrder() != null) { - tokens.addAll(visit(ctx.queryOrder())); - } - - if (sort.isSorted()) { - - if (ctx.queryOrder() != null) { - - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - } else { - - SPACE(tokens); - tokens.add(TOKEN_ORDER_BY); - } - - tokens.addAll(transformerSupport.generateOrderByArguments(primaryFromAlias, sort)); - } - } else { - - if (ctx.queryOrder() != null) { - tokens.addAll(visit(ctx.queryOrder())); - } - } - - return tokens; - } - - @Override - public List visitFromQuery(HqlParser.FromQueryContext ctx) { - - List tokens = newArrayList(); - - if (countQuery && !isSubquery(ctx) && ctx.selectClause() == null) { - - tokens.add(TOKEN_SELECT_COUNT); - - if (countProjection != null) { - tokens.add(new JpaQueryParsingToken(countProjection)); - } else { - tokens.add(new JpaQueryParsingToken(() -> primaryFromAlias, false)); - } - - tokens.add(TOKEN_CLOSE_PAREN); - } - - if (ctx.fromClause() != null) { - tokens.addAll(visit(ctx.fromClause())); - } - - if (ctx.whereClause() != null) { - tokens.addAll(visit(ctx.whereClause())); - } - - if (ctx.groupByClause() != null) { - tokens.addAll(visit(ctx.groupByClause())); - } - - if (ctx.havingClause() != null) { - tokens.addAll(visit(ctx.havingClause())); - } - - if (ctx.selectClause() != null) { - tokens.addAll(visit(ctx.selectClause())); - } - - return tokens; - } - - @Override - public List visitQueryOrder(HqlParser.QueryOrderContext ctx) { - - List tokens = newArrayList(); - - if (!countQuery) { - tokens.addAll(visit(ctx.orderByClause())); - } - - if (ctx.limitClause() != null) { - SPACE(tokens); - tokens.addAll(visit(ctx.limitClause())); - } - if (ctx.offsetClause() != null) { - tokens.addAll(visit(ctx.offsetClause())); - } - if (ctx.fetchClause() != null) { - tokens.addAll(visit(ctx.fetchClause())); - } - - return tokens; - } - - @Override - public List visitFromRoot(HqlParser.FromRootContext ctx) { - - List tokens = newArrayList(); - - if (ctx.entityName() != null) { - - tokens.addAll(visit(ctx.entityName())); - - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); - - if (primaryFromAlias == null && !isSubquery(ctx)) { - primaryFromAlias = tokens.get(tokens.size() - 1).getToken(); - } - } else { - - if (countQuery) { - - tokens.add(TOKEN_AS); - tokens.add(TOKEN_DOUBLE_UNDERSCORE); - - if (primaryFromAlias == null && !isSubquery(ctx)) { - primaryFromAlias = TOKEN_DOUBLE_UNDERSCORE.getToken(); - } - } - } - } else if (ctx.subquery() != null) { - - if (ctx.LATERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LATERAL())); - } - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - tokens.add(TOKEN_CLOSE_PAREN); - - if (ctx.variable() != null) { - tokens.addAll(visit(ctx.variable())); - - if (primaryFromAlias == null && !isSubquery(ctx)) { - primaryFromAlias = tokens.get(tokens.size() - 1).getToken(); - } - } - } - - return tokens; - } - - @Override - public List visitJoin(HqlParser.JoinContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.joinType())); - tokens.add(new JpaQueryParsingToken(ctx.JOIN())); - - if (!countQuery) { - if (ctx.FETCH() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FETCH())); - } - } - - tokens.addAll(visit(ctx.joinTarget())); - - if (ctx.joinRestriction() != null) { - tokens.addAll(visit(ctx.joinRestriction())); - } - - return tokens; - } - - @Override - public List visitJoinPath(HqlParser.JoinPathContext ctx) { - - List tokens = super.visitJoinPath(ctx); - - if (ctx.variable() != null) { - transformerSupport.registerAlias(tokens.get(tokens.size() - 1).getToken()); - } - - return tokens; - } - - @Override - public List visitJoinSubquery(HqlParser.JoinSubqueryContext ctx) { - - List tokens = super.visitJoinSubquery(ctx); - - if (ctx.variable() != null) { - transformerSupport.registerAlias(tokens.get(tokens.size() - 1).getToken()); - } - - return tokens; - } - - @Override - public List visitAlias(HqlParser.AliasContext ctx) { - - List tokens = super.visitAlias(ctx); - - if (primaryFromAlias == null && !isSubquery(ctx)) { - primaryFromAlias = tokens.get(tokens.size() - 1).getToken(); - } - - return tokens; - } - - @Override - public List visitVariable(HqlParser.VariableContext ctx) { - - List tokens = super.visitVariable(ctx); - - if (ctx.identifier() != null) { - transformerSupport.registerAlias(tokens.get(tokens.size() - 1).getToken()); - } - - return tokens; - } - - @Override - public List visitSelectClause(HqlParser.SelectClauseContext ctx) { - - List tokens = newArrayList(); - - tokens.add(new JpaQueryParsingToken(ctx.SELECT())); - - if (countQuery && !isSubquery(ctx)) { - tokens.add(TOKEN_COUNT_FUNC); - - if (countProjection != null) { - tokens.add(new JpaQueryParsingToken(countProjection)); - } - } - - if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); - } - - List selectionListTokens = visit(ctx.selectionList()); - - if (countQuery && !isSubquery(ctx)) { - - if (countProjection == null) { - - if (ctx.DISTINCT() != null) { - - List countSelection = QueryTransformers.filterCountSelection(selectionListTokens); - - if (countSelection.stream().anyMatch(hqlToken -> hqlToken.getToken().contains("new"))) { - // constructor - tokens.add(new JpaQueryParsingToken(() -> primaryFromAlias)); - } else { - // keep all the select items to distinct against - tokens.addAll(countSelection); - } - } else { - tokens.add(new JpaQueryParsingToken(() -> primaryFromAlias)); - } - } - - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else { - tokens.addAll(selectionListTokens); - } - - if (!projectionProcessed && !isSubquery(ctx)) { - projection = selectionListTokens; - projectionProcessed = true; - } - - return tokens; - } - - @Override - public List visitInstantiation(HqlParser.InstantiationContext ctx) { - - hasConstructorExpression = true; - - return super.visitInstantiation(ctx); - } - - static ArrayList newArrayList() { - return new ArrayList<>(); - } - -} 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 new file mode 100644 index 0000000000..46a22d1ecb --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java @@ -0,0 +1,223 @@ +/* + * 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.springframework.data.jpa.repository.query.QueryTokens.*; + +import java.util.List; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; + +/** + * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed HQL query. + * + * @author Greg Turnquist + * @author Christoph Strobl + * @author Oscar Fanchin + * @since 3.1 + */ +@SuppressWarnings("ConstantValue") +class HqlSortedQueryTransformer extends HqlQueryRenderer { + + private final JpaQueryTransformerSupport transformerSupport = new JpaQueryTransformerSupport(); + private final Sort sort; + private final @Nullable String primaryFromAlias; + private final @Nullable DtoProjectionTransformerDelegate dtoDelegate; + + HqlSortedQueryTransformer(Sort sort, HibernateQueryInformation queryInformation, + @Nullable ReturnedType returnedType) { + + Assert.notNull(sort, "Sort must not be null"); + Assert.notNull(queryInformation, "ParsedHqlQueryInformation must not be null"); + + this.sort = sort; + this.primaryFromAlias = queryInformation.getAlias(); + this.dtoDelegate = returnedType == null ? null : new DtoProjectionTransformerDelegate(returnedType); + } + + @Override + public QueryTokenStream visitQueryExpression(HqlParser.QueryExpressionContext ctx) { + + if (ObjectUtils.isEmpty(ctx.setOperator())) { + return super.visitQueryExpression(ctx); + } + + QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.withClause() != null) { + builder.appendExpression(visit(ctx.withClause())); + } + + List orderedQueries = ctx.orderedQuery(); + for (int i = 0; i < orderedQueries.size(); i++) { + + if (i != 0) { + builder.append(visit(ctx.setOperator(i - 1))); + } + + if (i == orderedQueries.size() - 1) { + builder.append(visitOrderedQuery(ctx.orderedQuery(i), this.sort)); + } else { + builder.append(visitOrderedQuery(ctx.orderedQuery(i), Sort.unsorted())); + } + } + + return builder; + } + + @Override + public QueryRendererBuilder visitOrderedQuery(HqlParser.OrderedQueryContext ctx) { + return visitOrderedQuery(ctx, this.sort); + } + + @Override + public QueryTokenStream visitSelectionList(HqlParser.SelectionListContext ctx) { + + QueryTokenStream tokenStream = super.visitSelectionList(ctx); + + if (dtoDelegate != null && dtoDelegate.canRewrite() && !isSubquery(ctx)) { + return dtoDelegate.getRewrittenSelectionList(); + } + + return tokenStream; + } + + @Override + public QueryTokenStream visitSelectExpression(HqlParser.SelectExpressionContext ctx) { + + QueryTokenStream selectItem = super.visitSelectExpression(ctx); + + if (dtoDelegate != null && dtoDelegate.applyRewriting() && ctx.instantiation() == null && !isSubquery(ctx)) { + dtoDelegate.appendSelectItem(QueryRenderer.ofExpression(selectItem)); + } + + return selectItem; + } + + @Override + public QueryTokenStream visitJoinPath(HqlParser.JoinPathContext ctx) { + + QueryTokenStream tokens = super.visitJoinPath(ctx); + + if (ctx.variable() != null && !isSubquery(ctx)) { + transformerSupport.registerAlias(tokens.getRequiredLast()); + } + + return tokens; + } + + @Override + public QueryTokenStream visitJoinSubquery(HqlParser.JoinSubqueryContext ctx) { + + QueryTokenStream tokens = super.visitJoinSubquery(ctx); + + if (ctx.variable() != null && !tokens.isEmpty() && !isSubquery(ctx)) { + transformerSupport.registerAlias(tokens.getRequiredLast()); + } + + return tokens; + } + + @Override + public QueryTokenStream visitJoinFunctionCall(HqlParser.JoinFunctionCallContext ctx) { + + QueryTokenStream tokens = super.visitJoinFunctionCall(ctx); + + if (ctx.variable() != null && !tokens.isEmpty()) { + transformerSupport.registerAlias(tokens.getRequiredLast()); + } + + return tokens; + } + + @Override + public QueryTokenStream visitVariable(HqlParser.VariableContext ctx) { + + QueryTokenStream tokens = super.visitVariable(ctx); + + if (ctx.identifier() != null && !tokens.isEmpty() && !isSubquery(ctx)) { + transformerSupport.registerAlias(tokens.getRequiredLast()); + } + + return tokens; + } + + private QueryRendererBuilder visitOrderedQuery(HqlParser.OrderedQueryContext ctx, Sort sort) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + if (ctx.query() != null) { + builder.append(visit(ctx.query())); + } else if (ctx.queryExpression() != null) { + + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.queryExpression())); + builder.append(TOKEN_CLOSE_PAREN); + } + + if (!isSubquery(ctx)) { + + if (ctx.queryOrder() != null) { + QueryTokenStream existingOrder = visit(ctx.queryOrder()); + if (sort.isSorted()) { + builder.appendInline(existingOrder); + } else { + builder.append(existingOrder); + } + } + + if (sort.isSorted()) { + + List sortBy = transformerSupport.orderBy(primaryFromAlias, sort); + + if (ctx.queryOrder() != null) { + + QueryRendererBuilder extension = QueryRenderer.builder().append(TOKEN_COMMA).append(sortBy); + + builder.appendInline(extension); + } else { + builder.append(TOKEN_ORDER_BY); + builder.append(sortBy); + } + } + } else { + + if (ctx.queryOrder() != null) { + builder.append(visit(ctx.queryOrder())); + } + } + + if (ctx.limitClause() != null) { + builder.appendExpression(visit(ctx.limitClause())); + } + + if (ctx.offsetClause() != null) { + builder.appendExpression(visit(ctx.offsetClause())); + } + + if (ctx.fetchClause() != null) { + builder.appendExpression(visit(ctx.fetchClause())); + } + + return builder; + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/InvalidJpaQueryMethodException.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/InvalidJpaQueryMethodException.java index a70c4beb29..14cdf678d1 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/InvalidJpaQueryMethodException.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/InvalidJpaQueryMethodException.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -15,6 +15,8 @@ */ package org.springframework.data.jpa.repository.query; +import java.io.Serial; + /** * Signals that we encountered an invalid query method. * @@ -23,7 +25,7 @@ */ public class InvalidJpaQueryMethodException extends RuntimeException { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; /** * Creates a new {@link InvalidJpaQueryMethodException} with the given message. 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 4ea5c2f5bc..ec5bbf729b 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -15,19 +15,22 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.jpa.repository.query.JSqlParserUtils.*; import static org.springframework.data.jpa.repository.query.QueryUtils.*; -import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.expression.Alias; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.CCJSqlParser; import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.ParseException; +import net.sf.jsqlparser.parser.feature.Feature; import net.sf.jsqlparser.schema.Column; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.delete.Delete; import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.merge.Merge; +import net.sf.jsqlparser.statement.select.Join; import net.sf.jsqlparser.statement.select.OrderByElement; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; @@ -36,18 +39,25 @@ import net.sf.jsqlparser.statement.select.Values; import net.sf.jsqlparser.statement.update.Update; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.ObjectInputStream; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; import java.util.List; -import java.util.Objects; import java.util.Set; -import java.util.stream.Collectors; +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; /** @@ -58,418 +68,449 @@ * @author Geoffrey Deremetz * @author Yanming Zhou * @author Christoph Strobl + * @author Diego Pedregal + * @author Soomin Kim * @since 2.7.0 */ public class JSqlParserQueryEnhancer implements QueryEnhancer { - private final DeclaredQuery query; - private final Statement statement; + private final QueryProvider query; private final ParsedType parsedType; + private final boolean hasConstructorExpression; + private final @Nullable String primaryAlias; + private final String projection; + private final Set joinAliases; + private final Set selectAliases; + 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; - try { - this.statement = CCJSqlParserUtil.parse(this.query.getQueryString()); - } catch (JSQLParserException e) { - throw new IllegalArgumentException("The query is not a valid SQL Query", e); - } + Statement statement = parseStatement(query.getQueryString(), Statement.class); this.parsedType = detectParsedType(statement); + this.hasConstructorExpression = QueryUtils.hasConstructorExpression(query.getQueryString()); + this.primaryAlias = detectAlias(this.parsedType, statement); + this.projection = detectProjection(statement); + this.selectAliases = Collections.unmodifiableSet(getSelectionAliases(statement)); + this.joinAliases = Collections.unmodifiableSet(getJoinAliases(statement)); + this.serialized = SerializationUtils.serialize(statement); } /** - * Detects what type of query is provided. + * Parses a query string with JSqlParser. * - * @return the parsed type + * @param sql the query to parse + * @param classOfT the query to parse + * @return the parsed query */ - private static ParsedType detectParsedType(Statement statement) { + static T parseStatement(String sql, Class classOfT) { - if (statement instanceof Insert) { - return ParsedType.INSERT; - } else if (statement instanceof Update) { - return ParsedType.UPDATE; - } else if (statement instanceof Delete) { - return ParsedType.DELETE; - } else if (statement instanceof Select) { - return ParsedType.SELECT; - } else if (statement instanceof Merge) { - return ParsedType.MERGE; - } else { - return ParsedType.OTHER; + try { + + CCJSqlParser parser = CCJSqlParserUtil.newParser(sql); + boolean allowComplex = parser.getConfiguration().getAsBoolean(Feature.allowComplexParsing); + try { + return classOfT.cast(parser.withAllowComplexParsing(true).Statement()); + } catch (ParseException ex) { + if (allowComplex && CCJSqlParserUtil.getNestingDepth(sql) <= CCJSqlParserUtil.ALLOWED_NESTING_DEPTH) { + // beware: the parser must not be reused, but needs to be re-initiated + parser = CCJSqlParserUtil.newParser(sql); + return classOfT.cast(parser.withAllowComplexParsing(true).Statement()); + } else { + throw ex; + } + } + + } catch (ParseException e) { + throw new IllegalArgumentException("The query you provided is not a valid SQL Query", e); } } - @Override - public String applySorting(Sort sort, @Nullable String alias) { + /** + * Resolves the alias for the entity to be retrieved from the given JPA query. Note that you only provide valid Query + * strings. Things such as from User u will throw an {@link IllegalArgumentException}. + * + * @return Might return {@literal null}. + */ + private static @Nullable String detectAlias(ParsedType parsedType, Statement statement) { - String queryString = query.getQueryString(); - Assert.hasText(queryString, "Query must not be null or empty"); + if (ParsedType.MERGE.equals(parsedType)) { - if (this.parsedType != ParsedType.SELECT) { - return queryString; - } + Merge mergeStatement = (Merge) statement; + + Alias alias = mergeStatement.getUsingAlias(); + return alias == null ? null : alias.getName(); - if (sort.isUnsorted()) { - return queryString; } - Select selectStatement = parseSelectStatement(queryString); + if (ParsedType.SELECT.equals(parsedType)) { - if (selectStatement instanceof SetOperationList setOperationList) { - return applySortingToSetOperationList(setOperationList, sort); + return doWithPlainSelect(statement, it -> it.getFromItem() == null || it.getFromItem().getAlias() == null, + it -> it.getFromItem().getAlias().getName(), () -> null); } - if (!(selectStatement instanceof PlainSelect selectBody)) { - return queryString; + return null; + } + + /** + * Returns the aliases used inside the selection part in the query. + * + * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}. + */ + private static Set getSelectionAliases(Statement statement) { + + if (statement instanceof SetOperationList sel) { + statement = sel.getSelect(0); } - Set joinAliases = getJoinAliases(selectBody); - Set selectionAliases = getSelectionAliases(selectBody); + return doWithPlainSelect(statement, it -> CollectionUtils.isEmpty(it.getSelectItems()), it -> { - List orderByElements = sort.stream() // - .map(order -> getOrderClause(joinAliases, selectionAliases, alias, order)) // - .toList(); + Set set = new HashSet<>(it.getSelectItems().size(), 1.0f); - if (CollectionUtils.isEmpty(selectBody.getOrderByElements())) { - selectBody.setOrderByElements(new ArrayList<>()); - } + for (SelectItem selectItem : it.getSelectItems()) { + Alias alias = selectItem.getAlias(); + if (alias != null) { + set.add(alias.getName()); + } + } - selectBody.getOrderByElements().addAll(orderByElements); + return set; - return selectStatement.toString(); + }, Collections::emptySet); } /** - * Returns the {@link SetOperationList} as a string query with {@link Sort}s applied in the right order. + * Returns the aliases used for {@code join}s. * - * @param setOperationListStatement - * @param sort - * @return + * @return a {@literal Set} of aliases used in the query. Guaranteed to be not {@literal null}. */ - private String applySortingToSetOperationList(SetOperationList setOperationListStatement, Sort sort) { + private static Set getJoinAliases(Statement statement) { - // special case: ValuesStatements are detected as nested OperationListStatements - if (setOperationListStatement.getSelects().stream().anyMatch(Values.class::isInstance)) { - return setOperationListStatement.toString(); + if (statement instanceof SetOperationList sel) { + statement = sel.getSelect(0); } - // if (CollectionUtils.isEmpty(setOperationListStatement.getOrderByElements())) { - if (setOperationListStatement.getOrderByElements() == null) { - setOperationListStatement.setOrderByElements(new ArrayList<>()); - } + return doWithPlainSelect(statement, it -> CollectionUtils.isEmpty(it.getJoins()), it -> { - List orderByElements = sort.stream() // - .map(order -> getOrderClause(Collections.emptySet(), Collections.emptySet(), null, order)) // - .toList(); - setOperationListStatement.getOrderByElements().addAll(orderByElements); + Set set = new HashSet<>(it.getJoins().size(), 1.0f); - return setOperationListStatement.toString(); + for (Join join : it.getJoins()) { + Alias alias = join.getRightItem().getAlias(); + if (alias != null) { + set.add(alias.getName()); + } + } + return set; + + }, Collections::emptySet); } /** - * Returns the aliases used inside the selection part in the query. + * 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 selectBody a {@link PlainSelect} containing a query. Must not be {@literal null}. - * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}. + * @param statement + * @param mapper + * @param fallback + * @param + * @return */ - private Set getSelectionAliases(PlainSelect selectBody) { + private static T doWithPlainSelect(Statement statement, java.util.function.Function mapper, + Supplier fallback) { - if (CollectionUtils.isEmpty(selectBody.getSelectItems())) { - return new HashSet<>(); - } - - return selectBody.getSelectItems().stream() // - .filter(SelectItem.class::isInstance) // - .map(item -> ((SelectItem) item).getAlias()) // - .filter(Objects::nonNull) // - .map(Alias::getName) // - .collect(Collectors.toSet()); + Predicate neverSkip = Predicates.isFalse(); + return doWithPlainSelect(statement, neverSkip, mapper, fallback); } /** - * Returns the aliases used inside the selection part in the query. + * Apply a {@link java.util.function.Function mapping function} to the {@link PlainSelect} of the given + * {@link Statement} is or contains a {@link PlainSelect}. + *

+ * The operation is only applied if {@link Predicate skipIf} returns {@literal false} for the given statement + * returning the fallback value from {@code fallback}. * - * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}. + * @param statement + * @param skipIf + * @param mapper + * @param fallback + * @param + * @return */ - Set getSelectionAliases() { + private static T doWithPlainSelect(Statement statement, Predicate skipIf, + java.util.function.Function mapper, Supplier fallback) { - if (this.parsedType != ParsedType.SELECT) { - return new HashSet<>(); + if (!(statement instanceof Select select)) { + return fallback.get(); + } + + 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 this.getSelectionAliases((PlainSelect) statement); + return mapper.apply(select.getPlainSelect()); } - /** - * Returns the aliases used for {@code join}s. - * - * @param query a query string to extract the aliases of joins from. Must not be {@literal null}. - * @return a {@literal Set} of aliases used in the query. Guaranteed to be not {@literal null}. - */ - private Set getJoinAliases(String query) { + private static String detectProjection(Statement statement) { - if (this.parsedType != ParsedType.SELECT) { - return new HashSet<>(); + if (!(statement instanceof Select select)) { + return ""; } - Select selectStatement = (Select) statement; - if (selectStatement instanceof PlainSelect selectBody) { - return getJoinAliases(selectBody); + if (select instanceof Values) { + return ""; } - return new HashSet<>(); - } + Select selectBody = select; - /** - * Returns the aliases used for {@code join}s. - * - * @param selectBody the selection body to extract the aliases of joins from. Must not be {@literal null}. - * @return a {@literal Set} of aliases used in the query. Guaranteed to be not {@literal null}. - */ - private Set getJoinAliases(PlainSelect selectBody) { + if (select instanceof SetOperationList setOperationList) { - if (CollectionUtils.isEmpty(selectBody.getJoins())) { - return new HashSet<>(); + // using the first one since for setoperations the projection has to be the same + selectBody = setOperationList.getSelects().get(0); } - return selectBody.getJoins().stream() // - .map(join -> join.getRightItem().getAlias()) // - .filter(Objects::nonNull) // - .map(Alias::getName) // - .collect(Collectors.toSet()); + 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(); + + }, () -> ""); } /** - * Returns the order clause for the given {@link Sort.Order}. Will prefix the clause with the given alias if the - * referenced property refers to a join alias, i.e. starts with {@code $alias.}. + * Detects what type of query is provided. * - * @param joinAliases the join aliases of the original query. Must not be {@literal null}. - * @param alias the alias for the root entity. May be {@literal null}. - * @param order the order object to build the clause for. Must not be {@literal null}. - * @return a {@link OrderByElement} containing an order clause. Guaranteed to be not {@literal null}. + * @return the parsed type */ - private OrderByElement getOrderClause(final Set joinAliases, final Set selectionAliases, - @Nullable final String alias, final Sort.Order order) { - - final OrderByElement orderByElement = new OrderByElement(); - orderByElement.setAsc(order.getDirection().isAscending()); - orderByElement.setAscDescPresent(true); - - final String property = order.getProperty(); - - checkSortExpression(order); - - if (selectionAliases.contains(property)) { - Expression orderExpression = order.isIgnoreCase() ? getJSqlLower(property) : new Column(property); + private static ParsedType detectParsedType(Statement statement) { - orderByElement.setExpression(orderExpression); - return orderByElement; + if (statement instanceof Insert) { + return ParsedType.INSERT; + } else if (statement instanceof Update) { + return ParsedType.UPDATE; + } else if (statement instanceof Delete) { + return ParsedType.DELETE; + } else if (statement instanceof Select) { + return ParsedType.SELECT; + } else if (statement instanceof Merge) { + return ParsedType.MERGE; + } else { + return ParsedType.OTHER; } + } - boolean qualifyReference = joinAliases // - .parallelStream() // - .map(joinAlias -> joinAlias.concat(".")) // - .noneMatch(property::startsWith); - - boolean functionIndicator = property.contains("("); + @Override + public boolean isSelectQuery() { + return this.parsedType == ParsedType.SELECT; + } - String reference = qualifyReference && !functionIndicator && StringUtils.hasText(alias) - ? String.format("%s.%s", alias, property) - : property; - Expression orderExpression = order.isIgnoreCase() ? getJSqlLower(reference) : new Column(reference); - orderByElement.setExpression(orderExpression); - return orderByElement; + @Override + public boolean hasConstructorExpression() { + return hasConstructorExpression; } @Override - public String detectAlias() { - return detectAlias(this.query.getQueryString()); + public @Nullable String detectAlias() { + return this.primaryAlias; } - /** - * Resolves the alias for the entity to be retrieved from the given JPA query. Note that you only provide valid Query - * strings. Things such as from User u will throw an {@link IllegalArgumentException}. - * - * @param query must not be {@literal null}. - * @return Might return {@literal null}. - */ - @Nullable - private String detectAlias(String query) { + @Override + public String getProjection() { + return this.projection; + } - if (ParsedType.MERGE.equals(this.parsedType)) { + public Set getSelectionAliases() { + return selectAliases; + } - Merge mergeStatement = (Merge) statement; - return detectAlias(mergeStatement); + @Override + public QueryProvider getQuery() { + return this.query; + } - } else if (ParsedType.SELECT.equals(this.parsedType)) { + @Override + public String rewrite(QueryRewriteInformation rewriteInformation) { - Select selectStatement = (Select) statement; + Sort sort = rewriteInformation.getSort(); + String queryString = query.getQueryString(); - /* - * 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 (!isSelectQuery() && sort.isSorted()) { + throw new IllegalStateException( + "Cannot apply sorting to %s statement. Sorting is only supported for SELECT statements." + .formatted(this.parsedType)); + } - return detectAlias(selectBody); + if (sort.isUnsorted()) { + return queryString; } - return null; + return applySorting(deserializeRequired(this.serialized, Select.class), sort, primaryAlias); } - /** - * Resolves the alias for the entity to be retrieved from the given {@link PlainSelect}. Note that you only provide - * valid Query strings. Things such as from User u will throw an {@link IllegalArgumentException}. - * - * @param selectBody must not be {@literal null}. - * @return Might return {@literal null}. - */ - @Nullable - private String detectAlias(PlainSelect selectBody) { + private String applySorting(@Nullable Select selectStatement, Sort sort, @Nullable String alias) { - if (selectBody.getFromItem() == null) { - return null; + Assert.notNull(selectStatement, "SelectStatement must not be null"); + + if (selectStatement instanceof SetOperationList setOperationList) { + return applySortingToSetOperationList(setOperationList, sort); } - Alias alias = selectBody.getFromItem().getAlias(); - return alias == null ? null : alias.getName(); - } + doWithPlainSelect(selectStatement, it -> { - /** - * Resolves the alias for the given {@link Merge} statement. - * - * @param mergeStatement must not be {@literal null}. - * @return Might return {@literal null}. - */ - @Nullable - private String detectAlias(Merge mergeStatement) { + List orderByElements = new ArrayList<>(16); + for (Sort.Order order : sort) { + orderByElements.add(getOrderClause(joinAliases, selectAliases, alias, order)); + } + + if (CollectionUtils.isEmpty(it.getOrderByElements())) { + it.setOrderByElements(orderByElements); + } else { + it.getOrderByElements().addAll(orderByElements); + } + + return null; + + }, () -> ""); - Alias alias = mergeStatement.getUsingAlias(); - return alias == null ? null : alias.getName(); + return selectStatement.toString(); } @Override + @SuppressWarnings("NullAway") public String createCountQueryFor(@Nullable String countProjection) { if (this.parsedType != ParsedType.SELECT) { - return this.query.getQueryString(); + throw new IllegalStateException( + "Cannot derive count query for %s statement. Count queries are only supported for SELECT statements." + .formatted( + this.parsedType)); } Assert.hasText(this.query.getQueryString(), "OriginalQuery must not be null or empty"); - Select selectStatement = parseSelectStatement(this.query.getQueryString()); + Statement statement = (Statement) deserialize(this.serialized); - /* - We only support count queries for {@link PlainSelect}. - */ - if (!(selectStatement instanceof PlainSelect selectBody)) { - return this.query.getQueryString(); - } + return doWithPlainSelect(statement, it -> createCountQueryFor(it, countProjection, primaryAlias), + this.query::getQueryString); + } + + private static String createCountQueryFor(PlainSelect selectBody, @Nullable String countProjection, + @Nullable String primaryAlias) { // remove order by selectBody.setOrderByElements(null); if (StringUtils.hasText(countProjection)) { - Function jSqlCount = getJSqlCount(Collections.singletonList(countProjection), false); - selectBody.setSelectItems(Collections.singletonList(SelectItem.from(jSqlCount))); - return selectBody.toString(); - } - - boolean distinct = selectBody.getDistinct() != null; - selectBody.setDistinct(null); // reset possible distinct + selectBody.setSelectItems( + Collections.singletonList(SelectItem.from(getJSqlCount(Collections.singletonList(countProjection), false)))); + } else { - String tableAlias = detectAlias(selectBody); - String countProperty = countPropertyNameForSelection(selectBody.getSelectItems(), distinct, tableAlias); + boolean distinct = selectBody.getDistinct() != null; + selectBody.setDistinct(null); // reset possible distinct - Function jSqlCount = getJSqlCount(Collections.singletonList(countProperty), distinct); - selectBody.setSelectItems(Collections.singletonList(SelectItem.from(jSqlCount))); + Function jSqlCount = getJSqlCount( + Collections.singletonList(countPropertyNameForSelection(selectBody.getSelectItems(), distinct, primaryAlias)), + distinct); + selectBody.setSelectItems(Collections.singletonList(SelectItem.from(jSqlCount))); + } return selectBody.toString(); } - @Override - public String getProjection() { + /** + * Returns the {@link SetOperationList} as a string query with {@link Sort}s applied in the right order. + * + * @param setOperationListStatement + * @param sort + * @return + */ + private static String applySortingToSetOperationList(SetOperationList setOperationListStatement, Sort sort) { - if (this.parsedType != ParsedType.SELECT) { - return ""; + // special case: ValuesStatements are detected as nested OperationListStatements + for (Select select : setOperationListStatement.getSelects()) { + if (select instanceof Values) { + return setOperationListStatement.toString(); + } } - Assert.hasText(query.getQueryString(), "Query must not be null or empty"); - - Select selectStatement = (Select) statement; - - if (selectStatement instanceof Values) { - return ""; + List orderByElements = new ArrayList<>(16); + for (Sort.Order order : sort) { + orderByElements.add(getOrderClause(Collections.emptySet(), Collections.emptySet(), null, order)); } - Select selectBody = selectStatement; - - if (selectStatement instanceof SetOperationList setOperationList) { - - // using the first one since for setoperations the projection has to be the same - selectBody = setOperationList.getSelects().get(0); - - if (!(selectBody instanceof PlainSelect)) { - return ""; - } + if (setOperationListStatement.getOrderByElements() == null) { + setOperationListStatement.setOrderByElements(orderByElements); + } else { + setOperationListStatement.getOrderByElements().addAll(orderByElements); } - return ((PlainSelect) selectBody).getSelectItems() // - .stream() // - .map(Object::toString) // - .collect(Collectors.joining(", ")).trim(); - } - - @Override - public Set getJoinAliases() { - return this.getJoinAliases(this.query.getQueryString()); + return setOperationListStatement.toString(); } /** - * Parses a query string with JSqlParser. + * Returns the order clause for the given {@link Sort.Order}. Will prefix the clause with the given alias if the + * referenced property refers to a join alias, i.e. starts with {@code $alias.}. * - * @param query the query to parse - * @return the parsed query + * @param joinAliases the join aliases of the original query. Must not be {@literal null}. + * @param alias the alias for the root entity. May be {@literal null}. + * @param order the order object to build the clause for. Must not be {@literal null}. + * @return a {@link OrderByElement} containing an order clause. Guaranteed to be not {@literal null}. */ - private T parseSelectStatement(String query, Class classOfT) { + private static OrderByElement getOrderClause(Set joinAliases, Set selectionAliases, + @Nullable String alias, Sort.Order order) { - try { - return classOfT.cast(CCJSqlParserUtil.parse(query)); - } catch (JSQLParserException e) { - throw new IllegalArgumentException("The query you provided is not a valid SQL Query", e); + OrderByElement orderByElement = new OrderByElement(); + orderByElement.setAsc(order.getDirection().isAscending()); + orderByElement.setAscDescPresent(true); + + String property = order.getProperty(); + + checkSortExpression(order); + + if (selectionAliases.contains(property)) { + + Expression orderExpression = order.isIgnoreCase() ? getJSqlLower(property) : new Column(property); + orderByElement.setExpression(orderExpression); + return orderByElement; } - } - /** - * Parses a query string with JSqlParser. - * - * @param query the query to parse - * @return the parsed query - */ - private Select parseSelectStatement(String query) { - return parseSelectStatement(query, Select.class); - } + boolean qualifyReference = true; + for (String joinAlias : joinAliases) { + if (property.startsWith(joinAlias.concat("."))) { + qualifyReference = false; + break; + } + } - /** - * Checks whether a given projection only contains a single column definition (aka without functions, etc.) - * - * @param projection the projection to analyse - * @return true when the projection only contains a single column definition otherwise false - */ - private boolean onlyASingleColumnProjection(List> projection) { + boolean functionIndicator = property.contains("("); - // this is unfortunately the only way to check without any hacky & hard string regex magic - return projection.size() == 1 && projection.get(0) instanceof SelectItem - && ((projection.get(0)).getExpression()) instanceof Column; + String reference = qualifyReference && !functionIndicator && StringUtils.hasText(alias) ? alias + "." + property + : property; + Expression orderExpression = order.isIgnoreCase() ? getJSqlLower(reference) : new Column(reference); + orderByElement.setExpression(orderExpression); + + switch (order.getNullHandling()) { + case NULLS_FIRST -> orderByElement.setNullOrdering(OrderByElement.NullOrdering.NULLS_FIRST); + case NULLS_LAST -> orderByElement.setNullOrdering(OrderByElement.NullOrdering.NULLS_LAST); + default -> { + // do nothing + } + } + + return orderByElement; } /** @@ -481,7 +522,7 @@ private boolean onlyASingleColumnProjection(List> projection) { * @param tableAlias the table alias which can be {@literal null}. * @return */ - private String countPropertyNameForSelection(List> selectItems, boolean distinct, + private static String countPropertyNameForSelection(List> selectItems, boolean distinct, @Nullable String tableAlias) { if (onlyASingleColumnProjection(selectItems)) { @@ -491,12 +532,20 @@ private String countPropertyNameForSelection(List> selectItems, bo return column.getFullyQualifiedName(); } - return query.isNativeQuery() ? (distinct ? "*" : "1") : tableAlias == null ? "*" : tableAlias; + return distinct ? ((tableAlias != null ? tableAlias + "." : "") + "*") : "1"; } - @Override - public DeclaredQuery getQuery() { - return this.query; + /** + * Checks whether a given projection only contains a single column definition (aka without functions, etc.) + * + * @param projection the projection to analyse. + * @return {@code true} when the projection only contains a single column definition otherwise {@code false}. + */ + private static boolean onlyASingleColumnProjection(List> projection) { + + // this is unfortunately the only way to check without any hacky & hard string regex magic + return projection.size() == 1 && projection.get(0) instanceof SelectItem + && ((projection.get(0)).getExpression()) instanceof Column; } /** @@ -511,7 +560,73 @@ public DeclaredQuery getQuery() { * */ enum ParsedType { - DELETE, UPDATE, SELECT, INSERT, MERGE, OTHER; + DELETE, UPDATE, SELECT, INSERT, MERGE, OTHER + } + + /** + * Deserialize the byte array into an object. + * + * @param bytes a serialized object + * @return the result of deserializing the 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) { + throw new IllegalArgumentException("Failed to deserialize object", ex); + } catch (ClassNotFoundException ex) { + throw new IllegalStateException("Failed to deserialize object type", ex); + } + } + + private static T deserializeRequired(byte @Nullable [] bytes, Class type) { + Object deserialize = deserialize(bytes); + if (deserialize != null) { + return type.cast(deserialize); + } + throw new IllegalStateException("Failed to deserialize object type"); + } + + /** + * Generates a count function call, based on the {@code countFields}. + * + * @param countFields the non-empty list of fields that are used for counting + * @param distinct if it should be a distinct count + * @return the generated count function call + */ + private static Function getJSqlCount(List countFields, boolean distinct) { + + List countColumns = new ArrayList<>(countFields.size()); + for (String countField : countFields) { + Column column = new Column(countField); + countColumns.add(column); + } + + ExpressionList countExpression = new ExpressionList<>(countColumns); + + return new Function() // + .withName("count") // + .withParameters(countExpression) // + .withDistinct(distinct); + } + + /** + * Generates a lower function call, based on the {@code column}. + * + * @param column the non-empty column to use as param for lower + * @return the generated lower function call + */ + private static Function getJSqlLower(String column) { + + List expressions = Collections.singletonList(new Column(column)); + ExpressionList lowerParamExpression = new ExpressionList<>(expressions); + + return new Function() // + .withName("lower") // + .withParameters(lowerParamExpression); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserUtils.java deleted file mode 100644 index efe56e1892..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserUtils.java +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2022-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 net.sf.jsqlparser.expression.Expression; -import net.sf.jsqlparser.expression.Function; -import net.sf.jsqlparser.expression.operators.relational.ExpressionList; -import net.sf.jsqlparser.schema.Column; - -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; - -/** - * A utility class for JSqlParser. - * - * @author Diego Krupitza - * @author Greg Turnquist - * @since 2.7.0 - */ -public final class JSqlParserUtils { - - private JSqlParserUtils() {} - - /** - * Generates a count function call, based on the {@code countFields}. - * - * @param countFields the non-empty list of fields that are used for counting - * @param distinct if it should be a distinct count - * @return the generated count function call - */ - public static Function getJSqlCount(final List countFields, final boolean distinct) { - - List countColumns = countFields // - .stream() // - .map(Column::new) // - .collect(Collectors.toList()); - - ExpressionList countExpression = new ExpressionList<>(countColumns); - - return new Function() // - .withName("count") // - .withParameters(countExpression) // - .withDistinct(distinct); - } - - /** - * Generates a lower function call, based on the {@code column}. - * - * @param column the non-empty column to use as param for lower - * @return the generated lower function call - */ - public static Function getJSqlLower(String column) { - - List expressions = Collections.singletonList(new Column(column)); - ExpressionList lowerParamExpression = new ExpressionList<>(expressions); - - return new Function() // - .withName("lower") // - .withParameters(lowerParamExpression); - } -} 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 e436624215..b3c1407279 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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -15,24 +15,22 @@ */ package org.springframework.data.jpa.repository.query; -import java.lang.reflect.Method; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - import jakarta.persistence.AttributeNode; import jakarta.persistence.EntityGraph; import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import jakarta.persistence.Subgraph; +import java.util.ArrayList; +import java.util.Collections; +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.ClassUtils; import org.springframework.util.ObjectUtils; -import org.springframework.util.ReflectionUtils; import org.springframework.util.StringUtils; /** @@ -48,38 +46,16 @@ */ public class Jpa21Utils { - private static final @Nullable Method GET_ENTITY_GRAPH_METHOD; - private static final boolean JPA21_AVAILABLE = ClassUtils.isPresent("jakarta.persistence.NamedEntityGraph", - Jpa21Utils.class.getClassLoader()); - - static { - - if (JPA21_AVAILABLE) { - GET_ENTITY_GRAPH_METHOD = ReflectionUtils.findMethod(EntityManager.class, "getEntityGraph", String.class); - } else { - GET_ENTITY_GRAPH_METHOD = null; - } - } - private Jpa21Utils() { // prevent instantiation } - public static QueryHints getFetchGraphHint(EntityManager em, @Nullable JpaEntityGraph entityGraph, - Class entityType) { + public static QueryHints getFetchGraphHint(EntityManager em, JpaEntityGraph entityGraph, Class entityType) { MutableQueryHints result = new MutableQueryHints(); - if (entityGraph == null) { - return result; - } - EntityGraph graph = tryGetFetchGraph(em, entityGraph, entityType); - if (graph == null) { - return result; - } - result.add(entityGraph.getType().getKey(), graph); return result; } @@ -88,30 +64,27 @@ public static QueryHints getFetchGraphHint(EntityManager em, @Nullable JpaEntity * Adds a JPA 2.1 fetch-graph or load-graph hint to the given {@link Query} if running under JPA 2.1. * * @see Jakarta - * Persistence Specfication - Use of Entity Graphs in find and query operations + * Persistence Specification - Use of Entity Graphs in find and query operations * @param em must not be {@literal null}. * @param jpaEntityGraph must not be {@literal null}. * @param entityType must not be {@literal null}. * @return the {@link EntityGraph} described by the given {@code entityGraph}. */ - @Nullable private static EntityGraph tryGetFetchGraph(EntityManager em, JpaEntityGraph jpaEntityGraph, Class entityType) { Assert.notNull(em, "EntityManager must not be null"); Assert.notNull(jpaEntityGraph, "EntityGraph must not be null"); Assert.notNull(entityType, "EntityType must not be null"); - Assert.isTrue(JPA21_AVAILABLE, "The EntityGraph-Feature requires at least a JPA 2.1 persistence provider"); - Assert.isTrue(GET_ENTITY_GRAPH_METHOD != null, - "It seems that you have the JPA 2.1 API but a JPA 2.0 implementation on the classpath"); + if (StringUtils.hasText(jpaEntityGraph.getName())) { - try { - // first check whether an entityGraph with that name is already registered. - return em.getEntityGraph(jpaEntityGraph.getName()); - } catch (Exception ex) { - // try to create and dynamically register the entityGraph - return createDynamicEntityGraph(em, jpaEntityGraph, entityType); + try { + // check whether an entityGraph with that name is already registered. + return em.getEntityGraph(jpaEntityGraph.getName()); + } catch (Exception ignore) {} } + + return createDynamicEntityGraph(em, jpaEntityGraph, entityType); } /** @@ -129,7 +102,6 @@ private static EntityGraph createDynamicEntityGraph(EntityManager em, JpaEnti Assert.notNull(em, "EntityManager must not be null"); Assert.notNull(jpaEntityGraph, "JpaEntityGraph must not be null"); Assert.notNull(entityType, "Entity type must not be null"); - Assert.isTrue(jpaEntityGraph.isAdHocEntityGraph(), "The given " + jpaEntityGraph + " is not dynamic"); EntityGraph entityGraph = em.createEntityGraph(entityType); configureFetchGraphFrom(jpaEntityGraph, entityGraph); @@ -217,8 +189,7 @@ private static boolean exists(String attributeNodeName, List> 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()); @@ -232,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> nodes) { + private static @Nullable AttributeNode findAttributeNode(String attributeNodeName, List> nodes) { for (AttributeNode node : nodes) { if (ObjectUtils.nullSafeEquals(node.getAttributeName(), attributeNodeName)) { @@ -252,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 851a867214..6293c14077 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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. @@ -36,41 +33,59 @@ */ public class JpaCountQueryCreator extends JpaQueryCreator { - private boolean distinct; + private final boolean distinct; /** - * 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.getMetamodel()); this.distinct = tree.isDistinct(); } - @Override - protected CriteriaQuery createCriteriaQuery(CriteriaBuilder builder, ReturnedType type) { + /** + * 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) { - return builder.createQuery(Long.class); + super(tree, returnedType, provider, templates, metamodel); + + this.distinct = tree.isDistinct(); } - @Override - @SuppressWarnings("unchecked") - protected CriteriaQuery complete(@Nullable Predicate predicate, Sort sort, - CriteriaQuery query, CriteriaBuilder builder, Root root) { + public JpaCountQueryCreator(PartTree tree, ReturnedType returnedType, ParameterMetadataProvider provider, + JpqlQueryTemplates templates, JpaEntityMetadata entityMetadata, Metamodel metamodel) { + + super(tree, false, returnedType, provider, templates, entityMetadata, metamodel); - CriteriaQuery select = query.select(getCountQuery(query, builder, root)); - return predicate == null ? select : select.where(predicate); + this.distinct = tree.isDistinct(); } - @SuppressWarnings("rawtypes") - private Expression getCountQuery(CriteriaQuery query, CriteriaBuilder builder, Root root) { - return distinct ? builder.countDistinct(root) : builder.count(root); + @Override + protected JpqlQueryBuilder.Select buildQuery(Sort sort) { + + JpqlQueryBuilder.SelectStep selectStep = JpqlQueryBuilder.selectFrom(getEntity()); + + 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 d1a6b45935..a40966156d 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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -15,12 +15,12 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.Arrays; import java.util.List; +import org.jspecify.annotations.Nullable; + import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.EntityGraph.EntityGraphType; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -29,12 +29,11 @@ * * @author Thomas Darimont * @author Mark Paluch + * @author Christoph Strobl * @since 1.6 */ public class JpaEntityGraph { - private static String[] EMPTY_ATTRIBUTE_PATHS = {}; - private final String name; private final EntityGraphType type; private final List attributePaths; @@ -46,8 +45,8 @@ public class JpaEntityGraph { * @param nameFallback must not be {@literal null} or empty. */ public JpaEntityGraph(EntityGraph entityGraph, String nameFallback) { - this(StringUtils.hasText(entityGraph.value()) ? entityGraph.value() : nameFallback, entityGraph.type(), entityGraph - .attributePaths()); + this(StringUtils.hasText(entityGraph.value()) ? entityGraph.value() : nameFallback, entityGraph.type(), + entityGraph.attributePaths()); } /** @@ -58,14 +57,14 @@ 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"); this.name = name; this.type = type; - this.attributePaths = Arrays.asList(attributePaths == null ? EMPTY_ATTRIBUTE_PATHS : attributePaths); + this.attributePaths = attributePaths != null ? List.of(attributePaths) : List.of(); } /** @@ -96,16 +95,6 @@ public List getAttributePaths() { return attributePaths; } - /** - * Return {@literal true} if this {@link JpaEntityGraph} needs to be generated on-the-fly. - * - * @return - * @since 1.9 - */ - public boolean isAdHocEntityGraph() { - return !attributePaths.isEmpty(); - } - @Override public String toString() { return "JpaEntityGraph [name=" + name + ", type=" + type + ", attributePaths=" + attributePaths.toString() + "]"; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityMetadata.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityMetadata.java index 3652a84551..f7cc6cc9a0 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityMetadata.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityMetadata.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. 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 25e7c25ca9..d8050137f0 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -15,21 +15,23 @@ */ 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 jakarta.persistence.metamodel.Metamodel; +import java.util.ArrayList; import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.Set; +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,37 +41,104 @@ */ class JpaKeysetScrollQueryCreator extends JpaQueryCreator { + private final Metamodel metamodel; private final JpaEntityInformation entityInformation; private final KeysetScrollPosition scrollPosition; + private final ParameterMetadataProvider provider; + private final List 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, false, type, provider, templates, entityInformation, em.getMetamodel()); + this.metamodel = em.getMetamodel(); this.entityInformation = entityInformation; this.scrollPosition = scrollPosition; + this.provider = provider; + } + + @Override + public List getBindings() { + + List partTreeBindings = super.getBindings(); + List bindings = new ArrayList<>(partTreeBindings.size() + this.syntheticBindings.size()); + bindings.addAll(partTreeBindings); + bindings.addAll(this.syntheticBindings); + + return bindings; } @Override - protected CriteriaQuery complete(@Nullable Predicate predicate, Sort sort, CriteriaQuery query, - CriteriaBuilder builder, Root root) { + protected JpqlQueryBuilder.AbstractJpqlQuery createQuery(JpqlQueryBuilder.@Nullable Predicate predicate, Sort sort) { KeysetScrollSpecification 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> cachedBindings = new LinkedHashMap<>(); + JpqlQueryBuilder.Predicate keysetPredicate = keysetSpec.createJpqlPredicate(metamodel, getFrom(), getEntity(), + (property, value) -> { + + Map 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 @@ -77,9 +146,7 @@ Collection getRequiredSelection(Sort sort, ReturnedType returnedType) { Sort sortToUse = KeysetScrollSpecification.createSort(scrollPosition, sort, entityInformation); - Set selection = new LinkedHashSet<>(returnedType.getInputProperties()); - sortToUse.forEach(it -> selection.add(it.getProperty())); - - return selection; + return KeysetScrollDelegate.getProjectionInputProperties(entityInformation, returnedType.getInputProperties(), + sortToUse); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaMetamodelEntityMetadata.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaMetamodelEntityMetadata.java new file mode 100644 index 0000000000..ed80223d3a --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaMetamodelEntityMetadata.java @@ -0,0 +1,53 @@ +/* + * 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.metamodel.EntityType; + +import org.springframework.util.Assert; + +/** + * Metamodel-based implementation for {@link JpaEntityMetadata}. + * + * @author Mark Paluch + * @since 4.0 + */ +public class JpaMetamodelEntityMetadata implements JpaEntityMetadata { + + private final EntityType entityType; + + /** + * Creates a new {@link JpaMetamodelEntityMetadata} for the given domain type. + * + * @param entityType must not be {@literal null}. + */ + public JpaMetamodelEntityMetadata(EntityType entityType) { + + Assert.notNull(entityType, "Entity type must not be null"); + this.entityType = entityType; + } + + @Override + public Class getJavaType() { + return entityType.getJavaType(); + } + + @Override + public String getEntityName() { + return entityType.getName(); + } + +} 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 220a285d8f..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -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 parameters) { + JpaParameters(List 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 6d760d5a3a..e77ab25c6e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -15,11 +15,20 @@ */ package org.springframework.data.jpa.repository.query; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Score; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.Similarity; 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 +40,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; } - @Nullable - public T getValue(Parameter parameter) { + public JpaParameters getParameters() { + return parameters; + } + + public @Nullable T getValue(Parameter parameter) { return super.getValue(parameter.getIndex()); } @@ -61,4 +76,54 @@ protected Object potentiallyUnwrap(Object parameterValue) { return parameterValue; } + /** + * Returns the {@link ScoringFunction}. + * + * @return + */ + public ScoringFunction getScoringFunction() { + return doWithScore(Score::getFunction, Score.class::isInstance, ScoringFunction::unspecified); + } + + /** + * Returns whether to normalize similarities (i.e. translate the database-specific score into {@link Similarity}). + * + * @return + */ + public boolean normalizeSimilarity() { + return doWithScore(it -> true, Similarity.class::isInstance, () -> false); + } + + /** + * Returns the {@link ScoringFunction}. + * + * @return + */ + public T doWithScore(Function function, Predicate scoreFilter, Supplier defaultValue) { + + Score score = getScore(); + if (score != null && scoreFilter.test(score)) { + return function.apply(score); + } + + JpaParameters parameters = getParameters(); + if (parameters.hasScoreRangeParameter()) { + + Range range = getScoreRange(); + + if (range != null && range.getLowerBound().isBounded() + && scoreFilter.test(range.getLowerBound().getValue().get())) { + return function.apply(range.getUpperBound().getValue().get()); + } + + if (range != null && range.getUpperBound().isBounded() + && scoreFilter.test(range.getUpperBound().getValue().get())) { + return function.apply(range.getUpperBound().getValue().get()); + } + + } + + return defaultValue.get(); + } + } 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 255ac86dc3..0624bc2421 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -15,34 +15,45 @@ */ 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; +import java.util.Arrays; import java.util.Collection; import java.util.Iterator; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Score; +import org.springframework.data.domain.ScoringFunction; import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata; +import org.springframework.data.domain.VectorScoringFunctions; +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,55 +67,105 @@ * @author Moritz Becker * @author Andrey Kovalev * @author Greg Turnquist + * @author Christoph Strobl + * @author Jinmyeong Kim */ -public class JpaQueryCreator extends AbstractQueryCreator, Predicate> { +public class JpaQueryCreator extends AbstractQueryCreator + implements JpqlQueryCreator { - private final CriteriaBuilder builder; - private final Root root; - private final CriteriaQuery query; - private final ParameterMetadataProvider provider; + private static final Map DISTANCE_FUNCTIONS = Map.of(VectorScoringFunctions.COSINE, + new DistanceFunction("cosine_distance", Sort.Direction.ASC), // + VectorScoringFunctions.EUCLIDEAN, new DistanceFunction("euclidean_distance", Sort.Direction.ASC), // + VectorScoringFunctions.TAXICAB, new DistanceFunction("taxicab_distance", Sort.Direction.ASC), // + VectorScoringFunctions.HAMMING, new DistanceFunction("hamming_distance", Sort.Direction.ASC), // + VectorScoringFunctions.DOT_PRODUCT, new DistanceFunction("negative_inner_product", Sort.Direction.ASC)); + + record DistanceFunction(String distanceFunction, Sort.Direction direction) { + + } + + private final boolean searchQuery; 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 SimilarityNormalizer similarityNormalizer; + 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 provider must not be {@literal null}. + * @param templates 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, false, type, provider, templates, em.getMetamodel()); + } + + public JpaQueryCreator(PartTree tree, ReturnedType type, ParameterMetadataProvider provider, + JpqlQueryTemplates templates, Metamodel metamodel) { + this(tree, false, type, provider, templates, metamodel); + } + + @SuppressWarnings({ "rawtypes", "unchecked" }) + public JpaQueryCreator(PartTree tree, boolean searchQuery, ReturnedType type, ParameterMetadataProvider provider, + JpqlQueryTemplates templates, Metamodel metamodel) { + this(tree, searchQuery, type, provider, templates, + new JpaMetamodelEntityMetadata(metamodel.entity(type.getDomainType())), metamodel); + } + + public JpaQueryCreator(PartTree tree, boolean searchQuery, ReturnedType type, ParameterMetadataProvider provider, + JpqlQueryTemplates templates, JpaEntityMetadata entityMetadata, Metamodel metamodel) { super(tree); + + this.searchQuery = searchQuery; 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(entityMetadata); + this.metamodel = metamodel; + this.similarityNormalizer = provider.getSimilarityNormalizer(); } - /** - * 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 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(); } /** @@ -112,102 +173,245 @@ protected CriteriaQuery createCriteriaQuery(CriteriaBuilder bu * * @return the parameterExpressions */ - public List> getParameterExpressions() { - return provider.getExpressions(); + public List getBindings() { + return provider.getBindings(); } @Override - protected Predicate create(Part part, Iterator iterator) { - return toPredicate(part, root); + public ParameterBinder getBinder() { + return ParameterBinderFactory.createBinder(provider.getParameters(), getBindings()); } @Override - protected Predicate and(Part part, Predicate base, Iterator iterator) { - return builder.and(base, toPredicate(part, root)); + protected JpqlQueryBuilder.Predicate create(Part part, Iterator iterator) { + return toPredicate(part); } @Override - protected Predicate or(Predicate base, Predicate predicate) { - return builder.or(base, predicate); + protected JpqlQueryBuilder.Predicate and(Part part, JpqlQueryBuilder.Predicate base, Iterator iterator) { + return base.and(toPredicate(part)); + } + + @Override + 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 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 complete(@Nullable Predicate predicate, Sort sort, - CriteriaQuery query, CriteriaBuilder builder, Root root) { + protected JpqlQueryBuilder.Select buildQuery(Sort sort) { + + JpqlQueryBuilder.Select select = doSelect(sort); + + if (tree.isDelete() || tree.isCountProjection()) { + return select; + } + + if (sort.isSorted()) { + + for (Sort.Order order : sort) { + + JpqlQueryBuilder.Expression expression; + QueryUtils.checkSortExpression(order); + + try { + expression = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, + PropertyPath.from(order.getProperty(), entityType.getJavaType())); + } catch (PropertyReferenceException e) { + + if (order instanceof JpaSort.JpaOrder jpaOrder && jpaOrder.isUnsafe()) { + expression = JpqlQueryBuilder.expression(order.getProperty()); + } else { + throw e; + } + } + + if (order.isIgnoreCase()) { + expression = JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expression); + } + + select.orderBy(JpqlQueryBuilder.orderBy(expression, order)); + } + } else { + + if (searchQuery) { + + DistanceFunction distanceFunction = DISTANCE_FUNCTIONS.get(provider.getScoringFunction()); + if (distanceFunction != null) { + select + .orderBy(JpqlQueryBuilder.orderBy(JpqlQueryBuilder.expression("distance"), distanceFunction.direction())); + } + } + } + + return select; + } + + private JpqlQueryBuilder.Select doSelect(Sort sort) { + + JpqlQueryBuilder.SelectStep selectStep = JpqlQueryBuilder.selectFrom(entity); + + if (tree.isDelete()) { + return selectStep.entity(); + } + + if (tree.isDistinct()) { + selectStep = selectStep.distinct(); + } if (returnedType.needsCustomConstruction()) { - Collection requiredSelection = getRequiredSelection(sort, returnedType); - List> selections = new ArrayList<>(); + Collection 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); + } - for (String property : requiredSelection) { + List paths = new ArrayList<>(requiredSelection.size()); + for (String selection : requiredSelection) { + paths.add(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, + PropertyPath.from(selection, returnedType.getDomainType()), true)); + } - PropertyPath path = PropertyPath.from(property, returnedType.getDomainType()); - selections.add(toExpressionRecursively(root, path, true).alias(property)); + JpqlQueryBuilder.Expression distance = null; + if (searchQuery) { + distance = getDistanceExpression(); } - Class typeToRead = returnedType.getReturnedType(); + if (useTupleQuery()) { - query = typeToRead.isInterface() // - ? query.multiselect(selections) // - : query.select((Selection) builder.construct(typeToRead, // - selections.toArray(new Selection[0]))); + if (searchQuery) { + paths.add((distance != null ? distance : JpqlQueryBuilder.literal(0)).as("distance")); + } + return selectStep.select(paths); + } else { - } else if (tree.isExistsProjection()) { + JpqlQueryBuilder.ConstructorExpression expression = new JpqlQueryBuilder.ConstructorExpression( + returnedType.getReturnedType().getName(), new JpqlQueryBuilder.Multiselect(entity, paths)); - if (root.getModel().hasSingleIdAttribute()) { + List selection = new ArrayList<>(2); + selection.add(expression); - SingularAttribute id = root.getModel().getId(root.getModel().getIdType().getJavaType()); - query = query.multiselect(root.get((SingularAttribute) id).alias(id.getName())); + if (searchQuery) { + selection.add((distance != null ? distance : JpqlQueryBuilder.literal(0)).as("distance")); + } + + return selectStep.select(selection); + } + } + + if (searchQuery) { + + JpqlQueryBuilder.Expression distance = getDistanceExpression(); + + if (distance != null) { + return selectStep.select(new JpqlQueryBuilder.Multiselect(entity, + Arrays.asList(new JpqlQueryBuilder.EntitySelection(entity), distance.as("distance")))); + } + } + + 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 { - query = query.multiselect(root.getModel().getIdClassAttributes().stream()// - .map(it -> (Selection) root.get((SingularAttribute) it).alias(it.getName())) - .collect(Collectors.toList())); + List paths = entityType.getIdClassAttributes().stream()// + .map(it -> JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, + PropertyPath.from(it.getName(), returnedType.getDomainType()), true)) + .toList(); + return selectStep.select(paths); } + } + if (tree.isCountProjection()) { + return selectStep.count(); } else { - query = query.select((Root) root); + return selectStep.entity(); } + } + + private JpqlQueryBuilder.@Nullable Expression getDistanceExpression() { - CriteriaQuery select = query.orderBy(QueryUtils.toOrders(sort, root, builder)); - return predicate == null ? select : select.where(predicate); + DistanceFunction distanceFunction = DISTANCE_FUNCTIONS.get(provider.getScoringFunction()); + + if (distanceFunction != null) { + JpqlQueryBuilder.PathExpression pas = JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, + getVectorPath(), true); + return JpqlQueryBuilder.function(distanceFunction.distanceFunction(), pas, + placeholder(provider.getVectorBinding())); + } + + return null; + } + + PropertyPath getVectorPath() { + + for (PartTree.OrPart parts : tree) { + for (Part part : parts) { + if (part.getType() == NEAR || part.getType() == WITHIN) { + return part.getProperty(); + } + } + } + + throw new IllegalStateException("No vector path found"); } Collection 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, similarityNormalizer).build(); } /** @@ -215,25 +419,23 @@ private Predicate toPredicate(Part part, Root root) { * * @author Phil Webb * @author Oliver Gierke + * @author Mark Paluch */ - @SuppressWarnings({ "unchecked", "rawtypes" }) private class PredicateBuilder { private final Part part; - private final Root root; + private final SimilarityNormalizer normalizer; /** - * 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}. + * @param normalizer must not be {@literal null}. */ - public PredicateBuilder(Part part, Root root) { + public PredicateBuilder(Part part, SimilarityNormalizer normalizer) { - Assert.notNull(part, "Part must not be null"); - Assert.notNull(root, "Root must not be null"); this.part = part; - this.root = root; + this.normalizer = normalizer; } /** @@ -241,78 +443,77 @@ 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 first = provider.next(part); - ParameterMetadata 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>) 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>) 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> propertyExpression = traversePath(root, property); - ParameterExpression 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 stringPath = getTypedPath(root, part); - Expression propertyExpression = upperIfIgnoreCase(stringPath); - Expression 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 truePath = getTypedPath(root, part); - return builder.isTrue(truePath); + return where.isTrue(); case FALSE: - Expression falsePath = getTypedPath(root, part); - return builder.isFalse(falsePath); + return where.isFalse(); case SIMPLE_PROPERTY: - ParameterMetadata expression = provider.next(part); - Expression path = getTypedPath(root, part); - return expression.isIsNullParameter() ? path.isNull() - : builder.equal(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression())); case NEGATING_SIMPLE_PROPERTY: - return builder.notEqual(upperIfIgnoreCase(getTypedPath(root, part)), - upperIfIgnoreCase(provider.next(part).getExpression())); + + PartTreeParameterBinding simple = provider.next(part); + + 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: @@ -320,77 +521,156 @@ public Predicate build() { throw new IllegalArgumentException("IsEmpty / IsNotEmpty can only be used on collection properties"); } - Expression> 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(); + case WITHIN: + case NEAR: + PartTreeParameterBinding vector = provider.next(part); + PartTreeParameterBinding within = provider.next(part); + + if (within.getValue() instanceof Range r) { + + Range range = (Range) r; + + if (range.getUpperBound().isBounded() || range.getUpperBound().isBounded()) { + + Range.Bound lower = range.getLowerBound(); + Range.Bound upper = range.getUpperBound(); + + String distanceFunction = getDistanceFunction(provider.getScoringFunction()); + JpqlQueryBuilder.Expression distance = JpqlQueryBuilder.function(distanceFunction, pas, + placeholder(vector)); + + JpqlQueryBuilder.Predicate lowerPredicate = null; + JpqlQueryBuilder.Predicate upperPredicate = null; + + // Score is a distance function, you typically want less when you specify a lower boundary, + // therefore lower and upper predicates are inverted. + if (lower.isBounded()) { + JpqlQueryBuilder.Expression distanceValue = placeholder(provider.lower(within, normalizer)); + lowerPredicate = getUpperPredicate(lower.isInclusive(), distance, distanceValue); + } + + if (upper.isBounded()) { + JpqlQueryBuilder.Expression distanceValue = placeholder(provider.upper(within, normalizer)); + upperPredicate = getLowerPredicate(upper.isInclusive(), distance, distanceValue); + } + + if (lowerPredicate != null && upperPredicate != null) { + return lowerPredicate.and(upperPredicate); + } else if (lowerPredicate != null) { + return lowerPredicate; + } else if (upperPredicate != null) { + return upperPredicate; + } + } + } + + if (within.getValue() instanceof Score score) { + + String distanceFunction = getDistanceFunction(score.getFunction()); + JpqlQueryBuilder.Expression distanceValue = placeholder(provider.normalize(within, normalizer)); + JpqlQueryBuilder.Expression distance = JpqlQueryBuilder.function(distanceFunction, pas, + placeholder(vector)); + return getUpperPredicate(true, distance, distanceValue); + } + + throw new InvalidDataAccessApiUsageException( + "Near/Within keywords must be used with a Score or Range type"); default: throw new IllegalArgumentException("Unsupported keyword " + type); } } - private Predicate isMember(CriteriaBuilder builder, Expression parameter, - Expression> property) { - return builder.isMember(parameter, property); + private JpqlQueryBuilder.Predicate getLowerPredicate(boolean inclusive, JpqlQueryBuilder.Expression lhs, + JpqlQueryBuilder.Expression distance) { + return doLower(inclusive, lhs, distance); + } + + private JpqlQueryBuilder.Predicate getUpperPredicate(boolean inclusive, JpqlQueryBuilder.Expression lhs, + JpqlQueryBuilder.Expression distance) { + return doUpper(inclusive, lhs, distance); } - private Predicate isNotMember(CriteriaBuilder builder, Expression parameter, - Expression> property) { - return builder.isNotMember(parameter, property); + private static JpqlQueryBuilder.Predicate doLower(boolean inclusive, JpqlQueryBuilder.Expression lhs, + JpqlQueryBuilder.Expression distance) { + return inclusive ? JpqlQueryBuilder.where(lhs).gte(distance) : JpqlQueryBuilder.where(lhs).gt(distance); + } + + private static JpqlQueryBuilder.Predicate doUpper(boolean inclusive, JpqlQueryBuilder.Expression lhs, + JpqlQueryBuilder.Expression distance) { + return inclusive ? JpqlQueryBuilder.where(lhs).lte(distance) : JpqlQueryBuilder.where(lhs).lt(distance); + } + + private static String getDistanceFunction(ScoringFunction scoringFunction) { + + DistanceFunction distanceFunction = JpaQueryCreator.DISTANCE_FUNCTIONS.get(scoringFunction); + + if (distanceFunction == null) { + throw new IllegalArgumentException( + "Unsupported ScoringFunction: %s. Make sure to declare a supported ScoringFunction when creating Score/Similarity instances." + .formatted(scoringFunction.getName())); + } + + return distanceFunction.distanceFunction(); + } + + /** + * 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 JpqlQueryBuilder.Expression potentiallyIgnoreCase(JpqlQueryBuilder.Origin source, PropertyPath path) { + return potentiallyIgnoreCase(path, JpqlQueryBuilder.expression(source, path)); + } + + /** + * 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 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 Expression upperIfIgnoreCase(Expression expression) { + private 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) builder.upper((Expression) expression); + return JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expressionValue); case WHEN_POSSIBLE: - if (canUpperCase(expression)) { - return (Expression) builder.upper((Expression) expression); + if (canUpperCase(path)) { + return JpqlQueryBuilder.function(templates.getIgnoreCaseOperator(), expressionValue); } case NEVER: default: - return (Expression) 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 getComparablePath(Root root, Part part) { - return getTypedPath(root, part); - } - - private Expression getTypedPath(Root root, Part part) { - return toExpressionRecursively(root, part.getProperty()); - } - - private Expression traversePath(Path root, PropertyPath path) { - - Path result = root.get(path.getSegment()); - return (Expression) (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 30f3742e28..24548c9164 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -15,123 +15,229 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.Set; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; + +import org.antlr.v4.runtime.BailErrorStrategy; +import org.antlr.v4.runtime.CharStream; +import org.antlr.v4.runtime.CharStreams; +import org.antlr.v4.runtime.CommonTokenStream; +import org.antlr.v4.runtime.Lexer; +import org.antlr.v4.runtime.Parser; +import org.antlr.v4.runtime.ParserRuleContext; +import org.antlr.v4.runtime.RecognitionException; +import org.antlr.v4.runtime.TokenStream; +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.lang.Nullable; -import org.springframework.util.Assert; +import org.springframework.data.repository.query.ReturnedType; /** - * Implementation of {@link QueryEnhancer} to enhance JPA queries using a {@link JpaQueryParserSupport}. + * Implementation of {@link QueryEnhancer} to enhance JPA queries using ANTLR parsers. * * @author Greg Turnquist * @author Mark Paluch + * @author Soomin Kim * @since 3.1 * @see JpqlQueryParser * @see HqlQueryParser * @see EqlQueryParser */ -class JpaQueryEnhancer implements QueryEnhancer { +class JpaQueryEnhancer implements QueryEnhancer { + + private final ParserRuleContext context; + private final Q queryInformation; + private final String projection; + private final SortedQueryRewriteFunction sortFunction; + private final BiFunction<@Nullable String, Q, ParseTreeVisitor> countQueryFunction; + + JpaQueryEnhancer(ParserRuleContext context, ParsedQueryIntrospector introspector, + SortedQueryRewriteFunction sortFunction, + BiFunction<@Nullable String, Q, ParseTreeVisitor> countQueryFunction) { + + this.context = context; + this.sortFunction = sortFunction; + this.countQueryFunction = countQueryFunction; + introspector.visit(context); + + this.queryInformation = introspector.getParsedQueryInformation(); - private final DeclaredQuery query; - private final JpaQueryParserSupport queryParser; + List tokens = queryInformation.getProjection(); + this.projection = tokens.isEmpty() ? "" : new QueryRenderer.TokenRenderer(tokens).render(); + } /** - * Initialize with an {@link JpaQueryParserSupport}. + * Parse the query and return the parser context (AST). This method attempts parsing the query using + * {@link PredictionMode#SLL} first to attempt a fast-path parse without using the context. If that fails, it retries + * using {@link PredictionMode#LL} which is much slower, however it allows for contextual ambiguity resolution. + */ + static

ParserRuleContext parse(String query, Function lexerFactoryFunction, + Function parserFactoryFunction, Function parseFunction) { + + P parser = getParser(query, lexerFactoryFunction, parserFactoryFunction); + + parser.getInterpreter().setPredictionMode(PredictionMode.SLL); + parser.setErrorHandler(new BailErrorStrategy() { + @Override + public void reportError(Parser recognizer, RecognitionException e) { + + // avoid BadJpqlGrammarException creation in the first pass. + // recover(…) is going to handle cancellation. + } + }); + + try { + + return parseFunction.apply(parser); + } catch (BadJpqlGrammarException | ParseCancellationException e) { + + parser = getParser(query, lexerFactoryFunction, parserFactoryFunction); + // fall back to LL(*)-based parsing + parser.getInterpreter().setPredictionMode(PredictionMode.LL); + + return parseFunction.apply(parser); + } + } + + private static

P getParser(String query, Function lexerFactoryFunction, + Function parserFactoryFunction) { + + Lexer lexer = lexerFactoryFunction.apply(CharStreams.fromString(query)); + P parser = parserFactoryFunction.apply(new CommonTokenStream(lexer)); + + String grammar = lexer.getGrammarFileName(); + int dot = grammar.lastIndexOf('.'); + if (dot != -1) { + grammar = grammar.substring(0, dot); + } + + configureParser(query, grammar.toUpperCase(), lexer, parser); + + return parser; + } + + /** + * Apply common configuration. * - * @param query - * @param queryParser + * @param query the query input to parse. + * @param grammar name of the grammar. + * @param lexer lexer to configure. + * @param parser parser to configure. */ - private JpaQueryEnhancer(DeclaredQuery query, JpaQueryParserSupport queryParser) { + static void configureParser(String query, String grammar, Lexer lexer, Parser parser) { + + BadJpqlGrammarErrorListener errorListener = new BadJpqlGrammarErrorListener(query, grammar); - this.query = query; - this.queryParser = queryParser; + lexer.removeErrorListeners(); + lexer.addErrorListener(errorListener); + + parser.removeErrorListeners(); + parser.addErrorListener(errorListener); } /** - * Factory method to create a {@link JpaQueryParserSupport} 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 forJpql(DeclaredQuery query) { - - Assert.notNull(query, "DeclaredQuery must not be null!"); - - return new JpaQueryEnhancer(query, new JpqlQueryParser(query.getQueryString())); + public static JpaQueryEnhancer forJpql(String query) { + return JpqlQueryParser.parseQuery(query); } /** - * Factory method to create a {@link JpaQueryParserSupport} 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 forHql(DeclaredQuery query) { - - Assert.notNull(query, "DeclaredQuery must not be null!"); - - return new JpaQueryEnhancer(query, new HqlQueryParser(query.getQueryString())); + public static JpaQueryEnhancer forHql(String query) { + return HqlQueryParser.parseQuery(query); } /** - * Factory method to create a {@link JpaQueryParserSupport} 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 forEql(DeclaredQuery query) { + public static JpaQueryEnhancer forEql(String query) { + return EqlQueryParser.parseQuery(query); + } - Assert.notNull(query, "DeclaredQuery must not be null!"); + /** + * @return the parser context (AST) representing the parsed query. + */ + ParserRuleContext getContext() { + return context; + } - return new JpaQueryEnhancer(query, new EqlQueryParser(query.getQueryString())); + /** + * @return the parsed query information. + */ + Q getQueryInformation() { + return queryInformation; } - protected JpaQueryParserSupport getQueryParsingStrategy() { - return queryParser; + @Override + public boolean isSelectQuery() { + return this.queryInformation.isSelectStatement(); } /** - * Adds an {@literal order by} clause to the JPA query. + * Checks if the select clause has a new constructor instantiation in the JPA query. * - * @param sort the sort specification to apply. - * @return + * @return Guaranteed to return {@literal true} or {@literal false}. */ @Override - public String applySorting(Sort sort) { - return queryParser.renderSortedQuery(sort); + public boolean hasConstructorExpression() { + return this.queryInformation.hasConstructorExpression(); } /** - * 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 + * Resolves the alias for the entity in the FROM clause from the JPA query. */ @Override - public String applySorting(Sort sort, String alias) { - return applySorting(sort); + public @Nullable String detectAlias() { + return this.queryInformation.getAlias(); } /** - * Resolves the alias for the entity in the FROM clause from the JPA query. Since the {@link JpaQueryParserSupport} - * can already find the alias when generating sorted and count queries, this is mainly to serve test cases. + * Looks up the projection of the JPA query. */ @Override - public String detectAlias() { - return queryParser.findAlias(); + public String getProjection() { + return this.projection; } /** - * Creates a count query from the original query, with no count projection. - * - * @return Guaranteed to be not {@literal null}; + * Look up the {@link DeclaredQuery} from the query parser. */ @Override - public String createCountQueryFor() { - return createCountQueryFor(null); + public DeclaredQuery getQuery() { + QueryTokenStream tokens = sortFunction.apply(Sort.unsorted(), this.queryInformation, null).visit(context); + return DeclaredQuery.jpqlQuery(QueryRenderer.TokenRenderer.render(tokens)); + } + + @Override + public String rewrite(QueryRewriteInformation rewriteInformation) { + + Sort sort = rewriteInformation.getSort(); + + if (!queryInformation.isSelectStatement() && sort.isSorted()) { + throw new IllegalStateException( + "Cannot apply sorting to %s statement. Sorting is only supported for SELECT statements." + .formatted(queryInformation.getStatementType())); + } + + return QueryRenderer.TokenRenderer.render( + sortFunction.apply(sort, this.queryInformation, rewriteInformation.getReturnedType()) + .visit(context)); } /** @@ -141,44 +247,112 @@ public String createCountQueryFor() { */ @Override public String createCountQueryFor(@Nullable String countProjection) { - return queryParser.createCountQuery(countProjection); + + if (!queryInformation.isSelectStatement()) { + throw new IllegalStateException( + "Cannot derive count query for %s statement. Count queries are only supported for SELECT statements." + .formatted( + queryInformation.getStatementType())); + } + + return QueryRenderer.TokenRenderer + .render(countQueryFunction.apply(countProjection, this.queryInformation).visit(context)); } /** - * Checks if the select clause has a new constructor instantiation in the JPA query. + * Functional interface to rewrite a query considering {@link Sort} and {@link ReturnedType}. The function returns a + * visitor object that can visit the parsed query tree. * - * @return Guaranteed to return {@literal true} or {@literal false}. + * @since 3.5 */ - @Override - public boolean hasConstructorExpression() { - return queryParser.hasConstructorExpression(); + @FunctionalInterface + interface SortedQueryRewriteFunction { + + ParseTreeVisitor apply(Sort sort, Q queryInformation, @Nullable ReturnedType returnedType); + } /** - * Looks up the projection of the JPA query. Since the {@link JpaQueryParserSupport} can already find the projection - * when generating sorted and count queries, this is mainly to serve test cases. + * Implements the {@code HQL} parsing operations of a {@link JpaQueryEnhancer} using the ANTLR-generated + * {@link HqlParser} and {@link HqlSortedQueryTransformer}. + * + * @author Greg Turnquist + * @author Mark Paluch + * @since 3.1 */ - @Override - public String getProjection() { - return queryParser.projection(); + static class HqlQueryParser extends JpaQueryEnhancer { + + private HqlQueryParser(String query) { + super(parse(query, HqlLexer::new, HqlParser::new, HqlParser::start), new HqlQueryIntrospector(), + HqlSortedQueryTransformer::new, HqlCountQueryTransformer::new); + } + + /** + * Parse a HQL query. + * + * @param query the query to parse. + * @return the query parser. + * @throws BadJpqlGrammarException in case of malformed query. + */ + public static HqlQueryParser parseQuery(String query) throws BadJpqlGrammarException { + return new HqlQueryParser(query); + } + } /** - * Since the {@link JpaQueryParserSupport} can already fully transform sorted and count queries by itself, this is a - * placeholder method. + * Implements the {@code EQL} parsing operations of a {@link JpaQueryEnhancer} using the ANTLR-generated + * {@link EqlParser}. * - * @return empty set + * @author Greg Turnquist + * @author Mark Paluch + * @since 3.2 */ - @Override - public Set getJoinAliases() { - return Set.of(); + static class EqlQueryParser extends JpaQueryEnhancer { + + private EqlQueryParser(String query) { + super(parse(query, EqlLexer::new, EqlParser::new, EqlParser::start), new EqlQueryIntrospector(), + EqlSortedQueryTransformer::new, EqlCountQueryTransformer::new); + } + + /** + * Parse a EQL query. + * + * @param query the query to parse. + * @return the query parser. + * @throws BadJpqlGrammarException in case of malformed query. + */ + public static EqlQueryParser parseQuery(String query) throws BadJpqlGrammarException { + return new EqlQueryParser(query); + } + } /** - * Look up the {@link DeclaredQuery} from the {@link JpaQueryParserSupport}. + * Implements the {@code JPQL} parsing operations of a {@link JpaQueryEnhancer} using the ANTLR-generated + * {@link JpqlParser} and {@link JpqlSortedQueryTransformer}. + * + * @author Greg Turnquist + * @author Mark Paluch + * @since 3.1 */ - @Override - public DeclaredQuery getQuery() { - return query; + static class JpqlQueryParser extends JpaQueryEnhancer { + + private JpqlQueryParser(String query) { + super(parse(query, JpqlLexer::new, JpqlParser::new, JpqlParser::start), new JpqlQueryIntrospector(), + JpqlSortedQueryTransformer::new, JpqlCountQueryTransformer::new); + } + + /** + * Parse a JPQL query. + * + * @param query the query to parse. + * @return the query parser. + * @throws BadJpqlGrammarException in case of malformed query. + */ + public static JpqlQueryParser parseQuery(String query) throws BadJpqlGrammarException { + return new JpqlQueryParser(query); + } } + } 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 59f161293d..78c220f54f 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -16,32 +16,43 @@ package org.springframework.data.jpa.repository.query; import jakarta.persistence.EntityManager; -import jakarta.persistence.NoResultException; import jakarta.persistence.Query; import jakarta.persistence.StoredProcedureQuery; +import jakarta.persistence.Tuple; import java.lang.reflect.Method; +import java.util.ArrayList; import java.util.Collection; import java.util.List; +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; +import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Score; +import org.springframework.data.domain.ScoringFunction; import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.SearchResult; +import org.springframework.data.domain.SearchResults; +import org.springframework.data.domain.Similarity; import org.springframework.data.domain.Slice; import org.springframework.data.domain.SliceImpl; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.repository.core.support.SurroundingTransactionDetectorMethodInterceptor; +import org.springframework.data.repository.query.ReturnedType; 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.CollectionUtils; import org.springframework.util.ReflectionUtils; /** @@ -80,19 +91,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; @@ -116,8 +120,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. @@ -130,6 +133,80 @@ protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccesso } } + static class SearchResultExecution extends JpaQueryExecution { + + private final JpaQueryExecution delegate; + private final ReturnedType returnedType; + private final ScoringFunction function; + private final boolean normalizeSimilarity; + private final SimilarityNormalizer normalizer; + + SearchResultExecution(JpaQueryExecution delegate, ReturnedType returnedType, ScoringFunction function, + boolean normalizeSimilarity) { + + this.delegate = delegate; + this.returnedType = returnedType; + this.function = function; + this.normalizeSimilarity = normalizeSimilarity; + this.normalizer = normalizeSimilarity ? SimilarityNormalizer.get(function) : SimilarityNormalizer.IDENTITY; + } + + @Override + protected @Nullable Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) { + + Object result = delegate.execute(query, accessor); + + if (result instanceof Tuple || result instanceof Object[]) { + return map(result); + } + + if (result instanceof Collection c) { + + List> objects = new ArrayList<>(c.size()); + + for (Object o : c) { + objects.add(o instanceof Tuple || o instanceof Object[] ? map(o) : new SearchResult<>(o, 0)); + } + + return new SearchResults<>(objects); + } + + return result; + } + + private @Nullable SearchResult map(Object result) { + + if (result instanceof Tuple t) { + + Object value = returnedType.needsCustomConstruction() ? t : t.get(0); + try { + return new SearchResult<>(value, getScore(t.get("distance", Number.class).doubleValue())); + } catch (RuntimeException e) { + return new SearchResult<>(value, getScore(0)); + } + } + + if (result instanceof Object[] objects) { + + Object value = returnedType.needsCustomConstruction() ? objects : objects[0]; + try { + + return new SearchResult<>(value, getScore(((Number) (objects[objects.length - 1])).doubleValue())); + } catch (RuntimeException e) { + return new SearchResult<>(value, getScore(0)); + } + } + + return null; + } + + private Score getScore(double score) { + return normalizeSimilarity ? Similarity.raw(normalizer.getSimilarity(score), function) + : Score.of(score, function); + } + + } + /** * Executes the query to return a {@link org.springframework.data.domain.Window} of entities. * @@ -148,7 +225,7 @@ static class ScrollExecution extends JpaQueryExecution { } @Override - @SuppressWarnings("unchecked") + @SuppressWarnings("NullAway") protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) { ScrollPosition scrollPosition = accessor.getScrollPosition(); @@ -195,6 +272,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) { @@ -202,13 +285,35 @@ 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) { + @SuppressWarnings("NullAway") + 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(); } } @@ -218,9 +323,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(); } } @@ -232,6 +337,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 @@ -240,16 +346,17 @@ 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"); Class returnType = method.getReturnType(); - boolean isVoid = ClassUtils.isAssignable(returnType, Void.class); - boolean isInt = ClassUtils.isAssignable(returnType, Integer.class); + boolean isVoid = org.springframework.data.util.ReflectionUtils.isVoid(returnType); + boolean isNumber = ClassUtils.isAssignable(Number.class, returnType); - Assert.isTrue(isInt || isVoid, - "Modifying queries can only use void or int/Integer as return type; Offending method: " + method); + Assert.isTrue(isNumber || isVoid, + "Modifying queries can only use void, int/Integer, or long/Long as return type; Offending method: " + method); this.em = em; this.flush = method.getFlushAutomatically(); @@ -289,16 +396,34 @@ public DeleteExecution(EntityManager em) { } @Override - protected Object doExecute(AbstractJpaQuery jpaQuery, JpaParametersParameterAccessor accessor) { + protected @Nullable Object doExecute(AbstractJpaQuery jpaQuery, JpaParametersParameterAccessor accessor) { Query query = jpaQuery.createQuery(accessor); List resultList = query.getResultList(); + Class returnType = jpaQuery.getQueryMethod().getReturnType(); + + boolean simpleBatch = ClassUtils.isAssignable(Number.class, returnType) + || org.springframework.data.util.ReflectionUtils.isVoid(returnType); + boolean collectionQuery = jpaQuery.getQueryMethod().isCollectionQuery(); + + if (!simpleBatch && !collectionQuery) { + + if (resultList.size() > 1) { + throw new IncorrectResultSizeDataAccessException( + "Delete query returned more than one element: expected 1, actual " + resultList.size(), 1, + resultList.size()); + } + } for (Object o : resultList) { em.remove(o); } - return jpaQuery.getQueryMethod().isCollectionQuery() ? resultList : resultList.size(); + if (simpleBatch) { + return resultList.size(); + } + + return collectionQuery ? resultList : CollectionUtils.firstElement(resultList); } } @@ -333,12 +458,13 @@ 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); StoredProcedureJpaQuery query = (StoredProcedureJpaQuery) jpaQuery; StoredProcedureQuery procedure = query.createQuery(accessor); + Class returnType = query.getQueryMethod().getReturnType(); try { @@ -350,7 +476,9 @@ protected Object doExecute(AbstractJpaQuery jpaQuery, JpaParametersParameterAcce throw new InvalidDataAccessApiUsageException(NO_SURROUNDING_TRANSACTION); } - return collectionQuery ? procedure.getResultList() : procedure.getSingleResult(); + if (!Map.class.isAssignableFrom(returnType)) { + return collectionQuery ? procedure.getResultList() : procedure.getSingleResult(); + } } return query.extractOutputValue(procedure); @@ -375,10 +503,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 8f1d3247df..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryFactory.java +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright 2013-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 jakarta.persistence.EntityManager; - -import org.springframework.data.jpa.repository.QueryRewriter; -import org.springframework.data.repository.query.QueryCreationException; -import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; -import org.springframework.data.repository.query.RepositoryQuery; -import org.springframework.expression.spel.standard.SpelExpressionParser; -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; - - private static final SpelExpressionParser PARSER = new SpelExpressionParser(); - - /** - * 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 countQueryString - * @param queryString must not be {@literal null}. - * @param evaluationContextProvider - * @return - */ - AbstractJpaQuery fromMethodWithQueryString(JpaQueryMethod method, EntityManager em, String queryString, - @Nullable String countQueryString, QueryRewriter queryRewriter, - QueryMethodEvaluationContextProvider evaluationContextProvider) { - - 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, evaluationContextProvider, - PARSER) - : new SimpleJpaQuery(method, em, queryString, countQueryString, queryRewriter, evaluationContextProvider, - PARSER); - } - - /** - * 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 8cf8d12125..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -21,17 +21,17 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + 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.RepositoryQuery; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -69,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); } @@ -108,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()); } } @@ -133,59 +127,62 @@ protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter quer * @author Thomas Darimont * @author Jens Schauder */ - private static class DeclaredQueryLookupStrategy extends AbstractQueryLookupStrategy { - - private final QueryMethodEvaluationContextProvider evaluationContextProvider; + 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, - QueryMethodEvaluationContextProvider evaluationContextProvider, QueryRewriterProvider queryRewriterProvider) { - - super(em, queryMethodFactory, queryRewriterProvider); + JpaQueryConfiguration configuration) { - this.evaluationContextProvider = evaluationContextProvider; + 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, evaluationContextProvider); + 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, evaluationContextProvider); + return createStringQuery(method, em, method.getDeclaredQuery(namedQueries.getQuery(name)), + getCountQuery(method, namedQueries, em), + configuration); } - RepositoryQuery query = NamedQuery.lookupFrom(method, em); + RepositoryQuery query = NamedQuery.lookupFrom(method, em, configuration); + + return query != null ? query : NO_QUERY; + } + + private @Nullable DeclaredQuery getCountQuery(JpaQueryMethod method, NamedQueries namedQueries, EntityManager em) { - return query != null // - ? query // - : NO_QUERY; + String query = doGetCountQuery(method, namedQueries, em); + + return StringUtils.hasText(query) ? method.getDeclaredQuery(query) : null; } - @Nullable - private String getCountQuery(JpaQueryMethod method, NamedQueries namedQueries, EntityManager em) { + private static @Nullable String doGetCountQuery(JpaQueryMethod method, NamedQueries namedQueries, + EntityManager em) { if (StringUtils.hasText(method.getCountQuery())) { return method.getCountQuery(); @@ -209,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); + } + } /** @@ -231,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) { + JpaQueryConfiguration configuration) { - super(em, queryMethodFactory, queryRewriterProvider); - - 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); } } @@ -265,30 +298,22 @@ 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}. + * @param configuration must not be {@literal null}. */ public static QueryLookupStrategy create(EntityManager em, JpaQueryMethodFactory queryMethodFactory, - @Nullable Key key, QueryMethodEvaluationContextProvider evaluationContextProvider, - QueryRewriterProvider queryRewriterProvider, EscapeCharacter escape) { + @Nullable Key key, JpaQueryConfiguration configuration) { Assert.notNull(em, "EntityManager must not be null"); - Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null"); - - switch (key != null ? key : Key.CREATE_IF_NOT_FOUND) { - case CREATE: - return new CreateQueryLookupStrategy(em, queryMethodFactory, queryRewriterProvider, escape); - case USE_DECLARED_QUERY: - return new DeclaredQueryLookupStrategy(em, queryMethodFactory, evaluationContextProvider, - queryRewriterProvider); - case CREATE_IF_NOT_FOUND: - return new CreateIfNotFoundQueryLookupStrategy(em, queryMethodFactory, - new CreateQueryLookupStrategy(em, queryMethodFactory, queryRewriterProvider, escape), - new DeclaredQueryLookupStrategy(em, queryMethodFactory, evaluationContextProvider, queryRewriterProvider), - queryRewriterProvider); - default: - throw new IllegalArgumentException(String.format("Unsupported query lookup strategy %s", key)); - } + Assert.notNull(configuration, "JpaQueryConfiguration must not be null"); + + return switch (key != null ? key : Key.CREATE_IF_NOT_FOUND) { + 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, 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 2bb15aa97d..219ce95791 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -22,11 +22,12 @@ import java.lang.reflect.Method; import java.util.Arrays; import java.util.Collections; -import java.util.HashSet; import java.util.List; -import java.util.Map; import java.util.Optional; 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; @@ -40,16 +41,14 @@ import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.core.RepositoryMetadata; -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.repository.query.QueryCreationException; import org.springframework.data.repository.query.QueryMethod; 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.ConcurrentReferenceHashMap; import org.springframework.util.StringUtils; /** @@ -73,20 +72,10 @@ public class JpaQueryMethod extends QueryMethod { * Persistence Specification: Persistent Fields and Properties - Paragraph starting with * "Collection-valued persistent...". */ - private static final Set> NATIVE_ARRAY_TYPES; + private static final Set> NATIVE_ARRAY_TYPES = Set.of(byte[].class, Byte[].class, char[].class, + Character[].class); private static final StoredProcedureAttributeSource storedProcedureAttributeSource = StoredProcedureAttributeSource.INSTANCE; - static { - - Set> types = new HashSet<>(); - types.add(byte[].class); - types.add(Byte[].class); - types.add(char[].class); - types.add(Character[].class); - - NATIVE_ARRAY_TYPES = Collections.unmodifiableSet(types); - } - private final QueryExtractor extractor; private final Method method; private final Class returnType; @@ -100,20 +89,35 @@ public class JpaQueryMethod extends QueryMethod { private final Lazy isCollectionQuery; private final Lazy isProcedureQuery; private final Lazy> entityMetadata; - private final Map, Optional> annotationCache; + private final Lazy> metaAnnotation; /** * Creates a {@link JpaQueryMethod}. * - * @param method must not be {@literal null} - * @param metadata must not be {@literal null} - * @param factory must not be {@literal null} - * @param extractor must not be {@literal null} + * @param method must not be {@literal null}. + * @param metadata must not be {@literal null}. + * @param factory must not be {@literal null}. + * @param extractor must not be {@literal null}. */ - protected JpaQueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory factory, + public JpaQueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory factory, QueryExtractor extractor) { + this(method, metadata, factory, extractor, JpaParameters::new); + } + + /** + * Creates a {@link JpaQueryMethod}. + * + * @param method must not be {@literal null}. + * @param metadata must not be {@literal null}. + * @param factory must not be {@literal null}. + * @param extractor must not be {@literal null}. + * @param parametersFunction function to obtain {@link JpaParameters}, must not be {@literal null}. + * @since 3.5 + */ + public JpaQueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory factory, + QueryExtractor extractor, Function parametersFunction) { - super(method, metadata, factory); + super(method, metadata, factory, parametersFunction); Assert.notNull(method, "Method must not be null"); Assert.notNull(extractor, "Query extractor must not be null"); @@ -142,11 +146,13 @@ protected JpaQueryMethod(Method method, RepositoryMetadata metadata, ProjectionF this.isCollectionQuery = Lazy.of(() -> super.isCollectionQuery() && !NATIVE_ARRAY_TYPES.contains(this.returnType)); this.isProcedureQuery = Lazy.of(() -> AnnotationUtils.findAnnotation(method, Procedure.class) != null); this.entityMetadata = Lazy.of(() -> new DefaultJpaEntityMetadata<>(getDomainClass())); - this.annotationCache = new ConcurrentReferenceHashMap<>(); + this.metaAnnotation = Lazy + .of(() -> Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(method, Meta.class))); - Assert.isTrue(!(isModifyingQuery() && getParameters().hasSpecialParameter()), - () -> String.format("Modifying method must not contain %s", Parameters.TYPES)); - assertParameterNamesInAnnotatedQuery(); + if (isModifyingQuery() && getParameters().hasSpecialParameter()) { + throw QueryCreationException.create(this, + String.format("Modifying method must not contain %s", Parameters.TYPES)); + } } private static Class potentiallyUnwrapReturnTypeFor(RepositoryMetadata metadata, Method method) { @@ -161,32 +167,7 @@ private static Class potentiallyUnwrapReturnTypeFor(RepositoryMetadata metada return returnType.getType(); } - private void assertParameterNamesInAnnotatedQuery() { - - String annotatedQuery = getAnnotatedQuery(); - - if (!DeclaredQuery.of(annotatedQuery, this.isNativeQuery.get()).hasNamedParameter()) { - return; - } - - for (Parameter parameter : getParameters()) { - - if (!parameter.isNamedParameter()) { - continue; - } - - if (!StringUtils.hasText(annotatedQuery) - || !annotatedQuery.contains(String.format(":%s", parameter.getName().get())) - && !annotatedQuery.contains(String.format("#%s", parameter.getName().get()))) { - throw new IllegalStateException( - String.format("Using named parameters for method %s but parameter '%s' not found in annotated query '%s'", - method, parameter.getName(), annotatedQuery)); - } - } - } - @Override - @SuppressWarnings({ "rawtypes", "unchecked" }) public JpaEntityMetadata getEntityInformation() { return this.entityMetadata.get(); } @@ -201,13 +182,6 @@ public boolean isModifyingQuery() { return modifying.getNullable() != null; } - @SuppressWarnings("unchecked") - private Optional doFindAnnotation(Class annotationType) { - - return (Optional) this.annotationCache.computeIfAbsent(annotationType, - it -> Optional.ofNullable(AnnotatedElementUtils.findMergedAnnotation(method, it))); - } - /** * Returns all {@link QueryHint}s annotated at this class. Note, that {@link QueryHints} * @@ -261,10 +235,19 @@ boolean applyHintsToCountQuery() { * * @return */ - QueryExtractor getQueryExtractor() { + public QueryExtractor getQueryExtractor() { return extractor; } + /** + * Returns the {@link Method}. + * + * @return + */ + Method getMethod() { + return method; + } + /** * Returns the actual return type of the method. * @@ -290,7 +273,7 @@ public boolean hasQueryMetaAttributes() { */ @Nullable Meta getMetaAnnotation() { - return doFindAnnotation(Meta.class).orElse(null); + return metaAnnotation.get().orElse(null); } /** @@ -315,14 +298,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; @@ -354,19 +343,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. @@ -390,6 +410,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() { @@ -402,7 +433,7 @@ public String getNamedQueryName() { * * @return */ - String getNamedCountQueryName() { + public String getNamedCountQueryName() { String annotatedName = getAnnotationValue("countName", String.class); return StringUtils.hasText(annotatedName) ? annotatedName : getNamedQueryName() + ".count"; @@ -438,7 +469,7 @@ private T getAnnotationValue(String attribute, Class type) { return getMergedOrDefaultAnnotationValue(attribute, Query.class, type); } - @SuppressWarnings({ "rawtypes", "unchecked" }) + @SuppressWarnings({ "rawtypes", "unchecked", "NullAway" }) private T getMergedOrDefaultAnnotationValue(String attribute, Class annotationType, Class targetType) { Annotation annotation = AnnotatedElementUtils.findMergedAnnotation(method, annotationType); @@ -449,11 +480,6 @@ private T getMergedOrDefaultAnnotationValue(String attribute, Class annotati return targetType.cast(AnnotationUtils.getValue(annotation, attribute)); } - @Override - protected Parameters createParameters(ParametersSource parametersSource) { - return new JpaParameters(parametersSource); - } - @Override public JpaParameters getParameters() { return (JpaParameters) super.getParameters(); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethodFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethodFactory.java index 24326fe0ae..a5b5213127 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethodFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethodFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryParserSupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryParserSupport.java deleted file mode 100644 index ca17005e77..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryParserSupport.java +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright 2022-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 static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; - -import java.util.List; - -import org.antlr.v4.runtime.Lexer; -import org.antlr.v4.runtime.Parser; -import org.antlr.v4.runtime.ParserRuleContext; -import org.antlr.v4.runtime.atn.PredictionMode; -import org.springframework.data.domain.Sort; -import org.springframework.data.util.Lazy; -import org.springframework.lang.Nullable; - -/** - * Operations needed to parse a JPA query. - * - * @author Greg Turnquist - * @author Mark Paluch - * @since 3.1 - */ -abstract class JpaQueryParserSupport { - - private final ParseState state; - - JpaQueryParserSupport(String query) { - this.state = new ParseState(query); - } - - /** - * Generate a query using the original query with an @literal order by} clause added (or amended) based upon the - * provider {@link Sort} parameter. - * - * @param sort can be {@literal null} - */ - String renderSortedQuery(Sort sort) { - - try { - return render(applySort(state.getContext(), sort)); - } catch (BadJpqlGrammarException e) { - throw new IllegalArgumentException(e); - } - } - - /** - * Generate a count-based query using the original query. - * - * @param countProjection - */ - String createCountQuery(@Nullable String countProjection) { - - try { - return render(doCreateCountQuery(state.getContext(), countProjection)); - } catch (BadJpqlGrammarException e) { - throw new IllegalArgumentException(e); - } - } - - /** - * Find the projection of the query. - */ - String projection() { - - try { - List tokens = doFindProjection(state.getContext()); - return tokens.isEmpty() ? "" : render(tokens); - } catch (BadJpqlGrammarException e) { - return ""; - } - } - - /** - * Find the alias of the query's primary FROM clause - * - * @return can be {@literal null} - */ - @Nullable - String findAlias() { - - try { - return doFindAlias(state.getContext()); - } catch (BadJpqlGrammarException e) { - return null; - } - } - - /** - * Discern if the query has a {@code new com.example.Dto()} DTO constructor in the select clause. - * - * @return Guaranteed to be {@literal true} or {@literal false}. - */ - boolean hasConstructorExpression() { - - try { - return doCheckForConstructor(state.getContext()); - } catch (BadJpqlGrammarException e) { - return false; - } - } - - /** - * Parse the JPA query using its corresponding ANTLR parser. - */ - protected abstract ParserRuleContext parse(String query); - - /** - * Apply common configuration (SLL prediction for performance, our own error listeners). - * - * @param query - * @param lexer - * @param parser - */ - static void configureParser(String query, Lexer lexer, Parser parser) { - - BadJpqlGrammarErrorListener errorListener = new BadJpqlGrammarErrorListener(query); - - lexer.removeErrorListeners(); - lexer.addErrorListener(errorListener); - - parser.getInterpreter().setPredictionMode(PredictionMode.SLL); - - parser.removeErrorListeners(); - parser.addErrorListener(errorListener); - } - - /** - * Create a {@link JpaQueryParsingToken}-based query with an {@literal order by} applied/amended based upon the - * {@link Sort} parameter. - * - * @param parsedQuery - * @param sort can be {@literal null} - */ - protected abstract List applySort(ParserRuleContext parsedQuery, Sort sort); - - /** - * Create a {@link JpaQueryParsingToken}-based count query. - * - * @param parsedQuery - * @param countProjection - */ - protected abstract List doCreateCountQuery(ParserRuleContext parsedQuery, - @Nullable String countProjection); - - @Nullable - protected abstract String doFindAlias(ParserRuleContext parsedQuery); - - /** - * Find the projection of the query's primary SELECT clause. - * - * @param parsedQuery - */ - protected abstract List doFindProjection(ParserRuleContext parsedQuery); - - protected abstract boolean doCheckForConstructor(ParserRuleContext parsedQuery); - - /** - * Parser state capturing the lazily-parsed parser context. - */ - class ParseState { - - private final Lazy parsedQuery; - private volatile @Nullable BadJpqlGrammarException error; - private final String query; - - public ParseState(String query) { - this.query = query; - this.parsedQuery = Lazy.of(() -> parse(query)); - } - - public ParserRuleContext getContext() { - - BadJpqlGrammarException error = this.error; - - if (error != null) { - throw error; - } - - try { - return parsedQuery.get(); - } catch (BadJpqlGrammarException e) { - this.error = error = e; - throw error; - } - } - - public String getQuery() { - return query; - } - } - -} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryParsingToken.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryParsingToken.java deleted file mode 100644 index 0882174ef4..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryParsingToken.java +++ /dev/null @@ -1,210 +0,0 @@ -/* - * Copyright 2022-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; -import java.util.function.Supplier; - -import org.antlr.v4.runtime.Token; -import org.antlr.v4.runtime.tree.TerminalNode; - -/** - * A value type used to represent a JPA query token. NOTE: Sometimes the token's value is based upon a value found later - * in the parsing process, so the text itself is wrapped in a {@link Supplier}. - * - * @author Greg Turnquist - * @since 3.1 - */ -class JpaQueryParsingToken { - - /** - * Commonly use tokens. - */ - public static final JpaQueryParsingToken TOKEN_COMMA = new JpaQueryParsingToken(","); - public static final JpaQueryParsingToken TOKEN_DOT = new JpaQueryParsingToken(".", false); - public static final JpaQueryParsingToken TOKEN_EQUALS = new JpaQueryParsingToken("="); - public static final JpaQueryParsingToken TOKEN_OPEN_PAREN = new JpaQueryParsingToken("(", false); - public static final JpaQueryParsingToken TOKEN_CLOSE_PAREN = new JpaQueryParsingToken(")"); - public static final JpaQueryParsingToken TOKEN_ORDER_BY = new JpaQueryParsingToken("order by"); - public static final JpaQueryParsingToken TOKEN_LOWER_FUNC = new JpaQueryParsingToken("lower(", false); - public static final JpaQueryParsingToken TOKEN_SELECT_COUNT = new JpaQueryParsingToken("select count(", false); - public static final JpaQueryParsingToken TOKEN_PERCENT = new JpaQueryParsingToken("%"); - public static final JpaQueryParsingToken TOKEN_COUNT_FUNC = new JpaQueryParsingToken("count(", false); - public static final JpaQueryParsingToken TOKEN_DOUBLE_PIPE = new JpaQueryParsingToken("||"); - public static final JpaQueryParsingToken TOKEN_OPEN_SQUARE_BRACKET = new JpaQueryParsingToken("[", false); - public static final JpaQueryParsingToken TOKEN_CLOSE_SQUARE_BRACKET = new JpaQueryParsingToken("]"); - public static final JpaQueryParsingToken TOKEN_COLON = new JpaQueryParsingToken(":", false); - public static final JpaQueryParsingToken TOKEN_QUESTION_MARK = new JpaQueryParsingToken("?", false); - public static final JpaQueryParsingToken TOKEN_OPEN_BRACE = new JpaQueryParsingToken("{", false); - public static final JpaQueryParsingToken TOKEN_CLOSE_BRACE = new JpaQueryParsingToken("}"); - public static final JpaQueryParsingToken TOKEN_CLOSE_SQUARE_BRACKET_BRACE = new JpaQueryParsingToken("]}"); - public static final JpaQueryParsingToken TOKEN_CLOSE_PAREN_BRACE = new JpaQueryParsingToken(")}"); - - public static final JpaQueryParsingToken TOKEN_DOUBLE_UNDERSCORE = new JpaQueryParsingToken("__"); - - public static final JpaQueryParsingToken TOKEN_AS = new JpaQueryParsingToken("AS"); - - public static final JpaQueryParsingToken TOKEN_DESC = new JpaQueryParsingToken("desc", false); - - public static final JpaQueryParsingToken TOKEN_ASC = new JpaQueryParsingToken("asc", false); - - public static final JpaQueryParsingToken TOKEN_WITH = new JpaQueryParsingToken("WITH"); - - public static final JpaQueryParsingToken TOKEN_NOT = new JpaQueryParsingToken("NOT"); - - public static final JpaQueryParsingToken TOKEN_MATERIALIZED = new JpaQueryParsingToken("materialized"); - - public static final JpaQueryParsingToken TOKEN_NULLS = new JpaQueryParsingToken("NULLS"); - - public static final JpaQueryParsingToken TOKEN_FIRST = new JpaQueryParsingToken("FIRST"); - - public static final JpaQueryParsingToken TOKEN_LAST = new JpaQueryParsingToken("LAST"); - - /** - * The text value of the token. - */ - private final Supplier token; - - /** - * Space|NoSpace after token is rendered? - */ - private final boolean space; - - JpaQueryParsingToken(Supplier token, boolean space) { - - this.token = token; - this.space = space; - } - - JpaQueryParsingToken(String token, boolean space) { - this(() -> token, space); - } - - JpaQueryParsingToken(Supplier token) { - this(token, true); - } - - JpaQueryParsingToken(String token) { - this(() -> token, true); - } - - JpaQueryParsingToken(TerminalNode node, boolean space) { - this(node.getText(), space); - } - - JpaQueryParsingToken(TerminalNode node) { - this(node.getText()); - } - - JpaQueryParsingToken(Token token, boolean space) { - this(token.getText(), space); - } - - JpaQueryParsingToken(Token token) { - this(token.getText(), true); - } - - /** - * Extract the token's value from it's {@link Supplier}. - */ - String getToken() { - return this.token.get(); - } - - /** - * Should we render a space after the token? - */ - boolean getSpace() { - return this.space; - } - - /** - * Compare whether the given {@link JpaQueryParsingToken token} is equal to the one held by this instance. - * - * @param token must not be {@literal null}. - * @return {@literal true} if both tokens are equals (using case-insensitive comparison). - */ - boolean isA(JpaQueryParsingToken token) { - return token.getToken().equalsIgnoreCase(this.getToken()); - } - - @Override - public String toString() { - return getToken(); - } - - /** - * Switch the last {@link JpaQueryParsingToken}'s spacing to {@literal true}. - */ - static void SPACE(List tokens) { - - if (!tokens.isEmpty()) { - - int index = tokens.size() - 1; - - JpaQueryParsingToken lastTokenWithSpacing = new JpaQueryParsingToken(tokens.get(index).token); - tokens.remove(index); - tokens.add(lastTokenWithSpacing); - } - } - - /** - * Switch the last {@link JpaQueryParsingToken}'s spacing to {@literal false}. - */ - static void NOSPACE(List tokens) { - - if (!tokens.isEmpty()) { - - int index = tokens.size() - 1; - - JpaQueryParsingToken lastTokenWithNoSpacing = new JpaQueryParsingToken(tokens.get(index).token, false); - tokens.remove(index); - tokens.add(lastTokenWithNoSpacing); - } - } - - /** - * Drop the last entry from the list of {@link JpaQueryParsingToken}s. - */ - static void CLIP(List tokens) { - - if (!tokens.isEmpty()) { - tokens.remove(tokens.size() - 1); - } - } - - /** - * Render a list of {@link JpaQueryParsingToken}s into a string. - * - * @param tokens - * @return rendered string containing either a query or some subset of that query - */ - static String render(List tokens) { - - StringBuilder results = new StringBuilder(); - - tokens.forEach(token -> { - - results.append(token.getToken()); - - if (token.getSpace()) { - results.append(" "); - } - }); - - return results.toString().trim(); - } -} 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 6c730e7924..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 @@ -1,6 +1,6 @@ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; +import static org.springframework.data.jpa.repository.query.QueryTokens.*; import java.util.ArrayList; import java.util.HashSet; @@ -9,14 +9,17 @@ 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; /** - * Transformational operations needed to support either {@link HqlQueryTransformer} or {@link JpqlQueryTransformer}. - * + * Transformational operations needed to support either {@link HqlSortedQueryTransformer} or + * {@link JpqlSortedQueryTransformer}. + * * @author Greg Turnquist * @author Donghun Shin * @since 3.1 @@ -29,11 +32,7 @@ class JpaQueryTransformerSupport { + "aliases used in the select clause; If you really want to use something other than that for sorting, please use " + "JpaSort.unsafe(…)"; - private Set projectionAliases; - - JpaQueryTransformerSupport() { - this.projectionAliases = new HashSet<>(); - } + private final Set projectionAliases = new HashSet<>(); /** * Register an {@literal alias} so it can later be evaluated when applying {@link Sort}s. @@ -44,36 +43,53 @@ void registerAlias(String token) { projectionAliases.add(token); } + void registerAlias(QueryToken token) { + projectionAliases.add(token.value()); + } + /** * Using the primary {@literal FROM} clause's alias and a {@link Sort}, construct all the {@literal ORDER BY} * arguments. - * + * * @param primaryFromAlias * @param sort * @return */ - List generateOrderByArguments(String primaryFromAlias, Sort sort) { + List orderBy(@Nullable String primaryFromAlias, Sort sort) { - List tokens = new ArrayList<>(); + List tokens = new ArrayList<>(); sort.forEach(order -> { checkSortExpression(order); + StringBuilder builder = new StringBuilder(); + if (order.isIgnoreCase()) { - tokens.add(TOKEN_LOWER_FUNC); + builder.append(TOKEN_LOWER_FUNC.value()); } - tokens.add(new JpaQueryParsingToken(() -> generateOrderByArgument(primaryFromAlias, order))); + builder.append(generateOrderByArgument(primaryFromAlias, order)); if (order.isIgnoreCase()) { - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_CLOSE_PAREN); } - tokens.add(order.isDescending() ? TOKEN_DESC : TOKEN_ASC); - tokens.add(TOKEN_COMMA); + builder.append(" "); + + builder.append(order.isDescending() ? TOKEN_DESC : TOKEN_ASC); + + if(order.getNullHandling() == NullHandling.NULLS_FIRST) { + builder.append(" NULLS FIRST"); + } else if (order.getNullHandling() == NullHandling.NULLS_LAST) { + builder.append(" NULLS LAST"); + } + + if (!tokens.isEmpty()) { + tokens.add(TOKEN_COMMA); + } + + tokens.add(QueryTokens.token(builder.toString())); }); - CLIP(tokens); return tokens; } @@ -98,7 +114,7 @@ private void checkSortExpression(Sort.Order order) { /** * Using the {@code primaryFromAlias} and the {@link org.springframework.data.domain.Sort.Order}, construct a suitable * argument to be added to an {@literal ORDER BY} expression. - * + * * @param primaryFromAlias * @param order * @return @@ -120,7 +136,7 @@ private String generateOrderByArgument(@Nullable String primaryFromAlias, Sort.O * @param primaryFromAlias * @return boolean whether or not to apply the primary FROM clause's alias as a prefix */ - private boolean shouldPrefixWithAlias(Sort.Order order, String primaryFromAlias) { + private boolean shouldPrefixWithAlias(Sort.Order order, @Nullable String primaryFromAlias) { // If there is no primary alias if (ObjectUtils.isEmpty(primaryFromAlias)) { 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 03c9d82adb..42aa4bf93f 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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -21,10 +21,11 @@ import java.sql.Blob; import java.sql.SQLException; +import org.jspecify.annotations.Nullable; + import org.springframework.core.convert.converter.Converter; import org.springframework.dao.CleanupFailureDataAccessException; import org.springframework.dao.DataRetrievalFailureException; -import org.springframework.lang.Nullable; import org.springframework.util.StreamUtils; /** @@ -34,7 +35,7 @@ * @author Mark Paluch * @since 1.6 */ -final class JpaResultConverters { +public final class JpaResultConverters { /** * {@code private} to prevent instantiation. @@ -46,13 +47,13 @@ private JpaResultConverters() {} * * @author Thomas Darimont */ - enum BlobToByteArrayConverter implements Converter { + public enum BlobToByteArrayConverter implements Converter { 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 new file mode 100644 index 0000000000..af7686fac2 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlCountQueryTransformer.java @@ -0,0 +1,167 @@ +/* + * 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.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.util.StringUtils; + +/** + * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed JPQL query into a + * {@code COUNT(…)} query. + * + * @author Greg Turnquist + * @author Mark Paluch + * @author Christoph Strobl + * @since 3.1 + */ +@SuppressWarnings("ConstantValue") +class JpqlCountQueryTransformer extends JpqlQueryRenderer { + + private final @Nullable String countProjection; + private final @Nullable String primaryFromAlias; + + JpqlCountQueryTransformer(@Nullable String countProjection, QueryInformation queryInformation) { + this.countProjection = countProjection; + this.primaryFromAlias = queryInformation.getAlias(); + } + + @Override + public QueryTokenStream visitSelectQuery(JpqlParser.SelectQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.appendExpression(visit(ctx.select_clause())); + builder.appendExpression(visit(ctx.from_clause())); + + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); + } + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } + + return builder; + } + + @Override + public QueryTokenStream visitFromQuery(JpqlParser.FromQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + QueryRendererBuilder countBuilder = QueryRenderer.builder(); + countBuilder.append(TOKEN_SELECT_COUNT); + + if (countProjection != null) { + countBuilder.append(QueryTokens.token(countProjection)); + } else { + if (primaryFromAlias == null) { + countBuilder.append(TOKEN_DOUBLE_UNDERSCORE); + } else { + countBuilder.append(QueryTokens.token(primaryFromAlias)); + } + } + + countBuilder.append(TOKEN_CLOSE_PAREN); + + builder.appendExpression(countBuilder); + + if (ctx.from_clause() != null) { + builder.appendExpression(visit(ctx.from_clause())); + if (primaryFromAlias == null) { + builder.append(TOKEN_AS); + builder.append(TOKEN_DOUBLE_UNDERSCORE); + } + } + + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); + } + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } + + return builder; + } + + @Override + public QueryRendererBuilder visitSelect_clause(JpqlParser.Select_clauseContext ctx) { + + boolean usesDistinct = ctx.DISTINCT() != null; + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(QueryTokens.expression(ctx.SELECT())); + builder.append(TOKEN_COUNT_FUNC); + + QueryRendererBuilder nested = QueryRenderer.builder(); + if (countProjection == null) { + if (usesDistinct) { + nested.append(QueryTokens.expression(ctx.DISTINCT())); + nested.append(getDistinctCountSelection(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA))); + } else if (StringUtils.hasText(primaryFromAlias)) { + nested.append(QueryTokens.token(primaryFromAlias)); + } else { + if (ctx.select_item().isEmpty()) { + // cannot happen as per grammar, but you never know… + nested.append(QueryTokens.token("1")); + } else { + nested.append(visit(ctx.select_item().get(0))); + } + } + } else { + if (usesDistinct) { + nested.append(QueryTokens.expression(ctx.DISTINCT())); + } + nested.append(QueryTokens.token(countProjection)); + } + + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); + + return builder; + } + + private QueryRendererBuilder getDistinctCountSelection(QueryTokenStream selectionListbuilder) { + + QueryRendererBuilder nested = new QueryRendererBuilder(); + CountSelectionTokenStream countSelection = CountSelectionTokenStream.create(selectionListbuilder); + + if (countSelection.requiresPrimaryAlias()) { + // constructor + if (primaryFromAlias == null) { + throw new IllegalStateException( + "Primary alias must be set for DISTINCT count selection using constructor expressions"); + } + nested.append(QueryTokens.token(primaryFromAlias)); + } else { + // keep all the select items to distinct against + nested.append(countSelection); + } + return nested; + } + +} 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..3cc0c764b6 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilder.java @@ -0,0 +1,1559 @@ +/* + * 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.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Sort; +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.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * A Domain-Specific Language to build JPQL queries using Java code. + * + * @author Mark Paluch + * @author Choi Wang Gyu + */ +@SuppressWarnings("JavadocDeclaration") +public final class JpqlQueryBuilder { + + private JpqlQueryBuilder() {} + + /** + * Create an {@link Entity} from the given {@link JpaEntityMetadata}. + * + * @param from the entity type to select from. + * @return + */ + public static Entity entity(JpaEntityMetadata from) { + return new Entity(from.getJavaType(), from.getEntityName(), + getAlias(from.getJavaType().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 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 paths) { + return new Select(postProcess(new ConstructorExpression(resultType, new Multiselect(from, paths))), from); + } + + @Override + public Select select(Collection paths) { + return new Select(postProcess(new Multiselect(from, paths)), from); + } + + @Override + public Select select(Selection selection) { + return new Select(postProcess(selection), from); + } + + Selection postProcess(Selection selection) { + return distinct ? new DistinctSelection(selection) : selection; + } + + }; + } + + private static String getAlias(String from, java.util.function.Predicate predicate, + Supplier 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 + * @return + * @since 4.0 + */ + public static Expression orderBy(Expression sortExpression) { + return new OrderExpression(sortExpression, null, Sort.NullHandling.NATIVE); + } + + /** + * Create a new ordering expression. + * + * @param sortExpression + * @param order + * @return + */ + public static Expression orderBy(Expression sortExpression, Sort.Order order) { + return new OrderExpression(sortExpression, order.getDirection(), order.getNullHandling()); + } + + /** + * Create a new ordering expression. + * + * @param sortExpression + * @param direction + * @return + * @since 4.0 + */ + public static Expression orderBy(Expression sortExpression, Sort.Direction direction) { + return new OrderExpression(sortExpression, direction, Sort.NullHandling.NATIVE); + } + + /** + * 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 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 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 paths) { + return instantiate(resultType.getName(), paths); + } + + /** + * Provide a constructor expression to instantiate {@code resultType}. + * + * @param resultType + * @param paths + * @returninstanti + */ + @CheckReturnValue + Select instantiate(String resultType, Collection paths); + + /** + * Specify a multi-select. + * + * @param paths + * @return + */ + @CheckReturnValue + Select select(Collection paths); + + /** + * Select a single attribute. + * + * @param path + * @return + */ + @CheckReturnValue + default Select select(JpqlQueryBuilder.PathExpression path) { + return select(List.of(path)); + } + + /** + * Select a single attribute. + * + * @param selection + * @return + */ + @CheckReturnValue + Select select(Selection selection); + + } + + public 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) { + + PropertyPath from = PropertyPath.from(path, entity.entityClass); + return new PathAndOrigin(from, entity, false); + } + + if (origin instanceof Join join) { + + Origin parent = join.source; + List segments = new ArrayList<>(); + segments.add(join.path); + while (!(parent instanceof Entity)) { + if (parent instanceof Join parentJoin) { + parent = parentJoin.source; + segments.add(parentJoin.path); + } else { + parent = null; + } + } + + Collections.reverse(segments); + segments.add(path); + PathAndOrigin joinedPath = path(parent, StringUtils.collectionToDelimitedString(segments, ".")); + return new PathAndOrigin(joinedPath.path().getLeafProperty(), origin, false); + } + + throw new IllegalStateException("🙈 Unsupported origin type: " + origin); + } + + /** + * Entity selection. + * + * @param source + */ + record EntitySelection(Entity source) implements Selection, Expression { + + @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, Expression { + + @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 paths) implements Selection { + + @Override + public String render(RenderContext context) { + + StringBuilder builder = new StringBuilder(); + + for (Expression path : paths) { + + if (!builder.isEmpty()) { + builder.append(", "); + } + + builder.append(path.render(context)); + if (!context.isConstructorContext() && path instanceof AliasedExpression ae) { + builder.append(" ").append(ae.getAlias()); + } + } + + return builder.toString(); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + + } + + /** + * Interface specifying a predicate or expression that can be rendered to {@code String}. + */ + public interface Renderable { + + /** + * Render the predicate or expression given {@link RenderContext}. + * + * @param context + * @return + */ + String render(RenderContext context); + + } + + /** + * {@code WHERE} predicate. + */ + public interface Predicate extends Renderable { + + /** + * {@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 extends Renderable { + + /** + * Create an {@link AliasedExpression} with the given {@code alias}. If the expression is already aliased, the + * previous alias is discarded and replaced with the new one. + * + * @param alias + * @return + */ + default AliasedExpression as(String alias) { + + if (this instanceof DefaultAliasedExpression de) { + return new DefaultAliasedExpression(de.delegate, alias); + } + + return new DefaultAliasedExpression(this, alias); + } + + } + + /** + * Aliased expression. + * + * @since 4.0 + */ + public interface AliasedExpression extends Expression { + + /** + * @return the expression alias. + */ + String getAlias(); + + } + + record DefaultAliasedExpression(Expression delegate, String alias) implements AliasedExpression { + + @Override + public String render(RenderContext context) { + return delegate.render(context); + } + + @Override + public String getAlias() { + return alias(); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + + } + + /** + * 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 joins = new LinkedHashMap<>(); + + private final List 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 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.getName(), 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, @org.springframework.lang.Nullable Sort.Direction direction, + Sort.NullHandling nullHandling) implements Expression { + + @Override + public String render(RenderContext context) { + + StringBuilder builder = new StringBuilder(); + + builder.append(sortExpression.render(context)); + + if (direction != null) { + + builder.append(" "); + builder.append(direction.isDescending() ? TOKEN_DESC : TOKEN_ASC); + + if (nullHandling == Sort.NullHandling.NULLS_FIRST) { + builder.append(" NULLS FIRST"); + } else if (nullHandling == 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 aliases; + private int counter; + + RenderContext(Map 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(); + + } + + /** + * The root entity. + */ + public static final class Entity implements Origin { + + private final Class entityClass; + private final String entity; + private final String alias; + + /** + * @param entityClass entity class. + * @param entity entity name (as in {@code @Entity(…)}). + * @param alias alias to use. + */ + Entity(Class entityClass, String entity, String alias) { + this.entityClass = entityClass; + this.entity = entity; + this.alias = alias; + } + + @Override + public String getName() { + return entity; + } + + 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.entityClass, that.entityClass) + && Objects.equals(this.alias, that.alias); + } + + @Override + public int hashCode() { + return Objects.hash(entity, entityClass, alias); + } + + @Override + public String toString() { + return "Entity[" + "entity=" + entity + ", " + "className=" + entityClass.getName() + ", " + "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 <collection>} predicate. + * + * @param value + * @return + */ + Predicate memberOf(Expression value); + + /** + * Create a {@code NOT MEMBER OF <collection>} 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 <>} (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 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) { + + Expression predicate = this.predicate; + String rendered = predicate.render(context); + + return (hasParenthesis(rendered) ? "%s %s %s" : "%s %s (%s)").formatted(path.render(context), operator, rendered); + } + + @Override + public String toString() { + return render(RenderContext.EMPTY); + } + + private static boolean hasParenthesis(String str) { + return str.startsWith("(") && str.endsWith(")"); + } + + } + + 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, AliasedExpression { + + @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()); + } + } + + @Override + public String getAlias() { + return path().getSegment(); + } + + } + + /** + * 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/OpenJpaQueryUtilsIntegrationTests.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/OpenJpaQueryUtilsIntegrationTests.java rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java index 2363f429d5..039392d571 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaQueryUtilsIntegrationTests.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 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 Oliver Gierke + * @author Mark Paluch */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaQueryUtilsIntegrationTests extends QueryUtilsIntegrationTests { +interface JpqlQueryCreator { + + boolean useTupleQuery(); + + String createQuery(Sort sort); + + List 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 new file mode 100644 index 0000000000..7ccb450c06 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryIntrospector.java @@ -0,0 +1,89 @@ +/* + * 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; + +/** + * {@link ParsedQueryIntrospector} for JPQL queries. + * + * @author Mark Paluch + * @author Christoph Strobl + * @author Soomin Kim + */ +@SuppressWarnings({ "UnreachableCode" }) +class JpqlQueryIntrospector extends JpqlBaseVisitor implements ParsedQueryIntrospector { + + private final JpqlQueryRenderer renderer = new JpqlQueryRenderer(); + private final QueryInformationHolder introspection = new QueryInformationHolder(); + + @Override + public QueryInformation getParsedQueryInformation() { + return new QueryInformation(introspection); + } + + @Override + public Void visitSelectQuery(JpqlParser.SelectQueryContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.SELECT); + return super.visitSelectQuery(ctx); + } + + @Override + public Void visitFromQuery(JpqlParser.FromQueryContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.SELECT); + return super.visitFromQuery(ctx); + } + + @Override + public Void visitUpdate_statement(JpqlParser.Update_statementContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.UPDATE); + return super.visitUpdate_statement(ctx); + } + + @Override + public Void visitDelete_statement(JpqlParser.Delete_statementContext ctx) { + + introspection.setStatementType(QueryInformation.StatementType.DELETE); + return super.visitDelete_statement(ctx); + } + + @Override + public Void visitSelect_clause(JpqlParser.Select_clauseContext ctx) { + + introspection.captureProjection(ctx.select_item(), renderer::visitSelect_item); + return super.visitSelect_clause(ctx); + } + + @Override + public Void visitRange_variable_declaration(JpqlParser.Range_variable_declarationContext ctx) { + + if (ctx.identification_variable() != null && !JpqlQueryRenderer.isSubquery(ctx) + && !JpqlQueryRenderer.isSetQuery(ctx)) { + introspection.capturePrimaryAlias(ctx.identification_variable().getText()); + } + + return super.visitRange_variable_declaration(ctx); + } + + @Override + public Void visitConstructor_expression(JpqlParser.Constructor_expressionContext ctx) { + + introspection.constructorExpressionPresent(); + return super.visitConstructor_expression(ctx); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryParser.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryParser.java deleted file mode 100644 index 98988cf032..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryParser.java +++ /dev/null @@ -1,134 +0,0 @@ -/* - * Copyright 2022-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; - -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; -import org.antlr.v4.runtime.ParserRuleContext; -import org.springframework.data.domain.Sort; -import org.springframework.lang.Nullable; - -/** - * Implements the {@code JPQL} parsing operations of a {@link JpaQueryParserSupport} using the ANTLR-generated - * {@link JpqlParser} and {@link JpqlQueryTransformer}. - * - * @author Greg Turnquist - * @author Mark Paluch - * @since 3.1 - */ -class JpqlQueryParser extends JpaQueryParserSupport { - - JpqlQueryParser(String query) { - super(query); - } - - /** - * Convenience method to parse a JPQL query. Will throw a {@link BadJpqlGrammarException} if the query is invalid. - * - * @param query - * @return a parsed query, ready for postprocessing - */ - public static ParserRuleContext parseQuery(String query) { - - JpqlLexer lexer = new JpqlLexer(CharStreams.fromString(query)); - JpqlParser parser = new JpqlParser(new CommonTokenStream(lexer)); - - configureParser(query, lexer, parser); - - return parser.start(); - } - - - /** - * Parse the query using {@link #parseQuery(String)}. - * - * @return a parsed query - */ - @Override - protected ParserRuleContext parse(String query) { - return parseQuery(query); - } - - /** - * Use the {@link JpqlQueryTransformer} to transform the original query into a query with the {@link Sort} applied. - * - * @param parsedQuery - * @param sort can be {@literal null} - * @return list of {@link JpaQueryParsingToken}s - */ - @Override - protected List applySort(ParserRuleContext parsedQuery, Sort sort) { - return new JpqlQueryTransformer(sort).visit(parsedQuery); - } - - /** - * Use the {@link JpqlQueryTransformer} to transform the original query into a count query. - * - * @param parsedQuery - * @param countProjection - * @return list of {@link JpaQueryParsingToken}s - */ - @Override - protected List doCreateCountQuery(ParserRuleContext parsedQuery, - @Nullable String countProjection) { - return new JpqlQueryTransformer(true, countProjection).visit(parsedQuery); - } - - /** - * Run the parsed query through {@link JpqlQueryTransformer} to find the primary FROM clause's alias. - * - * @param parsedQuery - * @return can be {@literal null} - */ - @Override - protected String doFindAlias(ParserRuleContext parsedQuery) { - - JpqlQueryTransformer transformVisitor = new JpqlQueryTransformer(); - transformVisitor.visit(parsedQuery); - return transformVisitor.getAlias(); - } - - /** - * Use {@link JpqlQueryTransformer} to find the projection of the query. - * - * @param parsedQuery - * @return - */ - @Override - protected List doFindProjection(ParserRuleContext parsedQuery) { - - JpqlQueryTransformer transformVisitor = new JpqlQueryTransformer(); - transformVisitor.visit(parsedQuery); - return transformVisitor.getProjection(); - } - - /** - * Use {@link JpqlQueryTransformer} to detect if the query uses a {@code new com.example.Dto()} DTO constructor in the - * primary select clause. - * - * @param parsedQuery - * @return Guaranteed to be {@literal true} or {@literal false}. - */ - @Override - protected boolean doCheckForConstructor(ParserRuleContext parsedQuery) { - - JpqlQueryTransformer transformVisitor = new JpqlQueryTransformer(); - transformVisitor.visit(parsedQuery); - return transformVisitor.hasConstructorExpression(); - } -} 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 492215e48f..4dd74b1c2f 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -15,2349 +15,982 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; +import static org.springframework.data.jpa.repository.query.QueryTokens.*; import java.util.ArrayList; import java.util.List; -import org.springframework.data.jpa.repository.query.JpqlParser.Reserved_wordContext; +import org.antlr.v4.runtime.ParserRuleContext; +import org.antlr.v4.runtime.RuleContext; +import org.antlr.v4.runtime.tree.ParseTree; +import org.antlr.v4.runtime.tree.RuleNode; +import org.antlr.v4.runtime.tree.TerminalNode; + +import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.util.CollectionUtils; /** * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that renders a JPQL query without making any changes. * * @author Greg Turnquist * @author Christoph Strobl + * @author Mark Paluch + * @author TaeHyun Kang * @since 3.1 */ @SuppressWarnings({ "ConstantConditions", "DuplicatedCode" }) -class JpqlQueryRenderer extends JpqlBaseVisitor> { - - @Override - public List visitStart(JpqlParser.StartContext ctx) { - return visit(ctx.ql_statement()); - } - - @Override - public List visitQl_statement(JpqlParser.Ql_statementContext ctx) { - - if (ctx.select_statement() != null) { - return visit(ctx.select_statement()); - } else if (ctx.update_statement() != null) { - return visit(ctx.update_statement()); - } else if (ctx.delete_statement() != null) { - return visit(ctx.delete_statement()); - } else { - return List.of(); - } - } - - @Override - public List visitSelect_statement(JpqlParser.Select_statementContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.select_clause())); - tokens.addAll(visit(ctx.from_clause())); - - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); - } +class JpqlQueryRenderer extends JpqlBaseVisitor { - if (ctx.groupby_clause() != null) { - tokens.addAll(visit(ctx.groupby_clause())); - } - - if (ctx.having_clause() != null) { - tokens.addAll(visit(ctx.having_clause())); - } - - if (ctx.orderby_clause() != null) { - tokens.addAll(visit(ctx.orderby_clause())); - } - - return tokens; - } + /** + * Is this AST tree a {@literal subquery}? + * + * @return {@literal true} is the query is a subquery; {@literal false} otherwise. + */ + static boolean isSubquery(ParserRuleContext ctx) { - @Override - public List visitUpdate_statement(JpqlParser.Update_statementContext ctx) { + while (ctx != null) { - List tokens = new ArrayList<>(); + if (ctx instanceof JpqlParser.SubqueryContext) { + return true; + } - tokens.addAll(visit(ctx.update_clause())); + if (ctx instanceof JpqlParser.Update_statementContext || ctx instanceof JpqlParser.Delete_statementContext) { + return false; + } - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); + ctx = ctx.getParent(); } - return tokens; + return false; } - @Override - public List visitDelete_statement(JpqlParser.Delete_statementContext ctx) { + /** + * Is this AST tree a {@literal set} query that has been added through {@literal UNION|INTERSECT|EXCEPT}? + * + * @return boolean + */ + static boolean isSetQuery(ParserRuleContext ctx) { - List tokens = new ArrayList<>(); + while (ctx != null) { - tokens.addAll(visit(ctx.delete_clause())); + if (ctx instanceof JpqlParser.Set_fuctionContext) { + return true; + } - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); + ctx = ctx.getParent(); } - return tokens; - } - - @Override - public List visitFrom_clause(JpqlParser.From_clauseContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.FROM(), true)); - tokens.addAll(visit(ctx.identification_variable_declaration())); - - ctx.identificationVariableDeclarationOrCollectionMemberDeclaration() - .forEach(identificationVariableDeclarationOrCollectionMemberDeclarationContext -> { - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(identificationVariableDeclarationOrCollectionMemberDeclarationContext)); - }); - SPACE(tokens); - - return tokens; - } - - @Override - public List visitIdentificationVariableDeclarationOrCollectionMemberDeclaration( - JpqlParser.IdentificationVariableDeclarationOrCollectionMemberDeclarationContext ctx) { - - if (ctx.identification_variable_declaration() != null) { - return visit(ctx.identification_variable_declaration()); - } else if (ctx.collection_member_declaration() != null) { - return visit(ctx.collection_member_declaration()); - } else { - return List.of(); - } + return false; } @Override - public List visitIdentification_variable_declaration( - JpqlParser.Identification_variable_declarationContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.range_variable_declaration())); - - ctx.join().forEach(joinContext -> { - tokens.addAll(visit(joinContext)); - }); - ctx.fetch_join().forEach(fetchJoinContext -> { - tokens.addAll(visit(fetchJoinContext)); - }); - - return tokens; + public QueryTokenStream visitStart(JpqlParser.StartContext ctx) { + return visit(ctx.ql_statement()); } @Override - public List visitRange_variable_declaration(JpqlParser.Range_variable_declarationContext ctx) { + public QueryTokenStream visitFrom_clause(JpqlParser.From_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.entity_name())); + builder.append(QueryTokens.expression(ctx.FROM())); + builder.appendInline(visit(ctx.identification_variable_declaration())); - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); + if (!ctx.identificationVariableDeclarationOrCollectionMemberDeclaration().isEmpty()) { + builder.append(TOKEN_COMMA); } - tokens.addAll(visit(ctx.identification_variable())); + builder.appendExpression(QueryTokenStream + .concat(ctx.identificationVariableDeclarationOrCollectionMemberDeclaration(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitJoin(JpqlParser.JoinContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.join_spec())); - tokens.addAll(visit(ctx.join_association_path_expression())); - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); - } - tokens.addAll(visit(ctx.identification_variable())); - if (ctx.join_condition() != null) { - tokens.addAll(visit(ctx.join_condition())); - } - - return tokens; - } - - @Override - public List visitFetch_join(JpqlParser.Fetch_joinContext ctx) { + public QueryTokenStream visitIdentificationVariableDeclarationOrCollectionMemberDeclaration( + JpqlParser.IdentificationVariableDeclarationOrCollectionMemberDeclarationContext ctx) { - List tokens = new ArrayList<>(); + if (ctx.subquery() != null) { - tokens.addAll(visit(ctx.join_spec())); - tokens.add(new JpaQueryParsingToken(ctx.FETCH())); - tokens.addAll(visit(ctx.join_association_path_expression())); + QueryRendererBuilder nested = QueryRenderer.builder(); + nested.append(TOKEN_OPEN_PAREN); + nested.appendInline(visit(ctx.subquery())); + nested.append(TOKEN_CLOSE_PAREN); - return tokens; - } + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.appendExpression(nested); - @Override - public List visitJoin_spec(JpqlParser.Join_specContext ctx) { + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); + } - List tokens = new ArrayList<>(); + if (ctx.identification_variable() != null) { + builder.appendExpression(visit(ctx.identification_variable())); + } - if (ctx.LEFT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LEFT())); - } - if (ctx.OUTER() != null) { - tokens.add(new JpaQueryParsingToken(ctx.OUTER())); + return builder; } - if (ctx.INNER() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INNER())); - } - if (ctx.JOIN() != null) { - tokens.add(new JpaQueryParsingToken(ctx.JOIN())); - } - - return tokens; - } - - @Override - public List visitJoin_condition(JpqlParser.Join_conditionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.ON())); - tokens.addAll(visit(ctx.conditional_expression())); - return tokens; + return super.visitIdentificationVariableDeclarationOrCollectionMemberDeclaration(ctx); } @Override - public List visitJoin_association_path_expression( + public QueryTokenStream visitJoin_association_path_expression( JpqlParser.Join_association_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.TREAT() == null) { if (ctx.join_collection_valued_path_expression() != null) { - tokens.addAll(visit(ctx.join_collection_valued_path_expression())); + builder.appendExpression(visit(ctx.join_collection_valued_path_expression())); } else if (ctx.join_single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.join_single_valued_path_expression())); + builder.appendExpression(visit(ctx.join_single_valued_path_expression())); } } else { + QueryRendererBuilder nested = QueryRenderer.builder(); + if (ctx.join_collection_valued_path_expression() != null) { - tokens.add(new JpaQueryParsingToken(ctx.TREAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.join_collection_valued_path_expression())); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.subtype())); - NOSPACE(tokens); - tokens.add(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) { - tokens.add(new JpaQueryParsingToken(ctx.TREAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.join_single_valued_path_expression())); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.subtype())); - NOSPACE(tokens); - tokens.add(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 tokens; + return builder; } @Override - public List visitJoin_collection_valued_path_expression( + public QueryTokenStream visitJoin_collection_valued_path_expression( JpqlParser.Join_collection_valued_path_expressionContext ctx) { - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); + List items = new ArrayList<>(2 + ctx.single_valued_embeddable_object_field().size()); - ctx.single_valued_embeddable_object_field().forEach(singleValuedEmbeddableObjectFieldContext -> { - tokens.addAll(visit(singleValuedEmbeddableObjectFieldContext)); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - }); + if (ctx.identification_variable() != null) { + items.add(ctx.identification_variable()); + } - tokens.addAll(visit(ctx.collection_valued_field())); + items.addAll(ctx.single_valued_embeddable_object_field()); + items.add(ctx.collection_valued_field()); - return tokens; + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } @Override - public List visitJoin_single_valued_path_expression( + public QueryTokenStream visitJoin_single_valued_path_expression( JpqlParser.Join_single_valued_path_expressionContext ctx) { - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.identification_variable())); - tokens.add(TOKEN_DOT); - - ctx.single_valued_embeddable_object_field().forEach(singleValuedEmbeddableObjectFieldContext -> { - tokens.addAll(visit(singleValuedEmbeddableObjectFieldContext)); - tokens.add(TOKEN_DOT); - }); + List items = new ArrayList<>(2 + ctx.single_valued_embeddable_object_field().size()); + if (ctx.identification_variable() != null) { + items.add(ctx.identification_variable()); + } - tokens.addAll(visit(ctx.single_valued_object_field())); + items.addAll(ctx.single_valued_embeddable_object_field()); + items.add(ctx.single_valued_object_field()); - return tokens; + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } @Override - public List visitCollection_member_declaration( - JpqlParser.Collection_member_declarationContext ctx) { + public QueryTokenStream visitCollection_member_declaration(JpqlParser.Collection_member_declarationContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder nested = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.IN(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.collection_valued_path_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + nested.append(QueryTokens.token(ctx.IN())); + nested.append(TOKEN_OPEN_PAREN); + nested.appendInline(visit(ctx.collection_valued_path_expression())); + nested.append(TOKEN_CLOSE_PAREN); + + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.appendExpression(nested); if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); + builder.append(QueryTokens.expression(ctx.AS())); } - tokens.addAll(visit(ctx.identification_variable())); + if (ctx.identification_variable() != null) { + builder.appendExpression(visit(ctx.identification_variable())); + } - return tokens; + return builder; } @Override - public List visitQualified_identification_variable( + public QueryTokenStream visitQualified_identification_variable( JpqlParser.Qualified_identification_variableContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.map_field_identification_variable() != null) { - tokens.addAll(visit(ctx.map_field_identification_variable())); + builder.append(visit(ctx.map_field_identification_variable())); } else if (ctx.identification_variable() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ENTRY())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.expression(ctx.ENTRY())); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.identification_variable())); + builder.append(TOKEN_CLOSE_PAREN); } - return tokens; + return builder; } @Override - public List visitMap_field_identification_variable( + public QueryTokenStream visitMap_field_identification_variable( JpqlParser.Map_field_identification_variableContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.KEY() != null) { - tokens.add(new JpaQueryParsingToken(ctx.KEY(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.KEY())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.identification_variable())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.VALUE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.VALUE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.VALUE())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.identification_variable())); + builder.append(TOKEN_CLOSE_PAREN); } - return tokens; + return builder; } @Override - public List visitSingle_valued_path_expression( - JpqlParser.Single_valued_path_expressionContext ctx) { + public QueryTokenStream visitSingle_valued_path_expression(JpqlParser.Single_valued_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.qualified_identification_variable() != null) { - tokens.addAll(visit(ctx.qualified_identification_variable())); + builder.append(visit(ctx.qualified_identification_variable())); } else if (ctx.qualified_identification_variable() != null) { - tokens.add(new JpaQueryParsingToken(ctx.TREAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.qualified_identification_variable())); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.subtype())); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.TREAT())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.qualified_identification_variable())); + builder.append(QueryTokens.expression(ctx.AS())); + builder.appendInline(visit(ctx.subtype())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.state_field_path_expression() != null) { - tokens.addAll(visit(ctx.state_field_path_expression())); + builder.append(visit(ctx.state_field_path_expression())); } else if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); - } - - return tokens; - } - - @Override - public List visitGeneral_identification_variable( - JpqlParser.General_identification_variableContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.map_field_identification_variable() != null) { - tokens.addAll(visit(ctx.map_field_identification_variable())); + builder.append(visit(ctx.single_valued_object_path_expression())); } - return tokens; + return builder; } @Override - public List visitGeneral_subpath(JpqlParser.General_subpathContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitGeneral_subpath(JpqlParser.General_subpathContext ctx) { if (ctx.simple_subpath() != null) { - tokens.addAll(visit(ctx.simple_subpath())); + return visit(ctx.simple_subpath()); } else if (ctx.treated_subpath() != null) { - tokens.addAll(visit(ctx.treated_subpath())); + List items = new ArrayList<>(1 + ctx.single_valued_object_field().size()); - ctx.single_valued_object_field().forEach(singleValuedObjectFieldContext -> { - tokens.add(TOKEN_DOT); - tokens.addAll(visit(singleValuedObjectFieldContext)); - }); + items.add(ctx.treated_subpath()); + items.addAll(ctx.single_valued_object_field()); + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } - return tokens; + return QueryTokenStream.empty(); } @Override - public List visitSimple_subpath(JpqlParser.Simple_subpathContext ctx) { + public QueryTokenStream visitSimple_subpath(JpqlParser.Simple_subpathContext ctx) { - List tokens = new ArrayList<>(); + List items = new ArrayList<>(1 + ctx.single_valued_object_field().size()); - tokens.addAll(visit(ctx.general_identification_variable())); - NOSPACE(tokens); + items.add(ctx.general_identification_variable()); + items.addAll(ctx.single_valued_object_field()); - ctx.single_valued_object_field().forEach(singleValuedObjectFieldContext -> { - tokens.add(TOKEN_DOT); - tokens.addAll(visit(singleValuedObjectFieldContext)); - NOSPACE(tokens); - }); - SPACE(tokens); - - return tokens; + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } @Override - public List visitTreated_subpath(JpqlParser.Treated_subpathContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitTreated_subpath(JpqlParser.Treated_subpathContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.TREAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.general_subpath())); - SPACE(tokens); - tokens.add(new JpaQueryParsingToken(ctx.AS())); - tokens.addAll(visit(ctx.subtype())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - return tokens; - } - - @Override - public List visitState_field_path_expression( - JpqlParser.State_field_path_expressionContext ctx) { - - List tokens = new ArrayList<>(); + nested.appendExpression(visit(ctx.general_subpath())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.subtype())); - tokens.addAll(visit(ctx.general_subpath())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - tokens.addAll(visit(ctx.state_field())); + builder.append(QueryTokens.token(ctx.TREAT())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitState_valued_path_expression( - JpqlParser.State_valued_path_expressionContext ctx) { + public QueryTokenStream visitState_field_path_expression(JpqlParser.State_field_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.state_field_path_expression() != null) { - tokens.addAll(visit(ctx.state_field_path_expression())); - } else if (ctx.general_identification_variable() != null) { - tokens.addAll(visit(ctx.general_identification_variable())); - } + builder.appendInline(visit(ctx.general_subpath())); + builder.append(TOKEN_DOT); + builder.appendInline(visit(ctx.state_field())); - return tokens; + return builder; } @Override - public List visitSingle_valued_object_path_expression( + public QueryTokenStream visitSingle_valued_object_path_expression( JpqlParser.Single_valued_object_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.general_subpath())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - tokens.addAll(visit(ctx.single_valued_object_field())); + builder.appendInline(visit(ctx.general_subpath())); + builder.append(TOKEN_DOT); + builder.appendInline(visit(ctx.single_valued_object_field())); - return tokens; + return builder; } @Override - public List visitCollection_valued_path_expression( + public QueryTokenStream visitCollection_valued_path_expression( JpqlParser.Collection_valued_path_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.general_subpath())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - tokens.addAll(visit(ctx.collection_value_field())); + builder.appendInline(visit(ctx.general_subpath())); + builder.append(TOKEN_DOT); + builder.appendInline(visit(ctx.collection_value_field())); - return tokens; + return builder; } @Override - public List visitUpdate_clause(JpqlParser.Update_clauseContext ctx) { + public QueryTokenStream visitUpdate_clause(JpqlParser.Update_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.UPDATE())); - tokens.addAll(visit(ctx.entity_name())); + builder.append(QueryTokens.expression(ctx.UPDATE())); + builder.appendExpression(visit(ctx.entity_name())); if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); + builder.append(QueryTokens.expression(ctx.AS())); } + if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); + builder.appendExpression(visit(ctx.identification_variable())); } - tokens.add(new JpaQueryParsingToken(ctx.SET())); + builder.append(QueryTokens.expression(ctx.SET())); + builder.append(QueryTokenStream.concat(ctx.update_item(), this::visit, TOKEN_COMMA)); - ctx.update_item().forEach(updateItemContext -> { - tokens.addAll(visit(updateItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); - - return tokens; + return builder; } @Override - public List visitUpdate_item(JpqlParser.Update_itemContext ctx) { + public QueryTokenStream visitUpdate_item(JpqlParser.Update_itemContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); - List tokens = new ArrayList<>(); + List items = new ArrayList<>(3 + ctx.single_valued_embeddable_object_field().size()); if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); + items.add(ctx.identification_variable()); } - ctx.single_valued_embeddable_object_field().forEach(singleValuedEmbeddableObjectFieldContext -> { - tokens.addAll(visit(singleValuedEmbeddableObjectFieldContext)); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - }); + items.addAll(ctx.single_valued_embeddable_object_field()); if (ctx.state_field() != null) { - tokens.addAll(visit(ctx.state_field())); + items.add(ctx.state_field()); } else if (ctx.single_valued_object_field() != null) { - tokens.addAll(visit(ctx.single_valued_object_field())); + items.add(ctx.single_valued_object_field()); } - tokens.add(TOKEN_EQUALS); - tokens.addAll(visit(ctx.new_value())); + builder.appendInline(QueryTokenStream.concat(items, this::visit, TOKEN_DOT)); + builder.append(TOKEN_EQUALS); + builder.append(visit(ctx.new_value())); - return tokens; + return builder; } @Override - public List visitNew_value(JpqlParser.New_valueContext ctx) { - - if (ctx.scalar_expression() != null) { - return visit(ctx.scalar_expression()); - } else if (ctx.simple_entity_expression() != null) { - return visit(ctx.simple_entity_expression()); - } else if (ctx.NULL() != null) { - return List.of(new JpaQueryParsingToken(ctx.NULL())); - } else { - return List.of(); - } - } - - @Override - public List visitDelete_clause(JpqlParser.Delete_clauseContext ctx) { + public QueryTokenStream visitSelect_clause(JpqlParser.Select_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = prepareSelectClause(ctx); - tokens.add(new JpaQueryParsingToken(ctx.DELETE())); - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - tokens.addAll(visit(ctx.entity_name())); - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); - } - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } + builder.appendExpression(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } - @Override - public List visitSelect_clause(JpqlParser.Select_clauseContext ctx) { + QueryRendererBuilder prepareSelectClause(JpqlParser.Select_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.SELECT())); + builder.append(QueryTokens.expression(ctx.SELECT())); if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); - } - - ctx.select_item().forEach(selectItemContext -> { - tokens.addAll(visit(selectItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); - - return tokens; - } - - @Override - public List visitSelect_item(JpqlParser.Select_itemContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.select_expression())); - SPACE(tokens); - - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); + builder.append(QueryTokens.expression(ctx.DISTINCT())); } - if (ctx.result_variable() != null) { - tokens.addAll(visit(ctx.result_variable())); - } - - return tokens; + return builder; } @Override - public List visitSelect_expression(JpqlParser.Select_expressionContext ctx) { - - if (ctx.single_valued_path_expression() != null) { - return visit(ctx.single_valued_path_expression()); - } else if (ctx.scalar_expression() != null) { - return visit(ctx.scalar_expression()); - } else if (ctx.aggregate_expression() != null) { - return visit(ctx.aggregate_expression()); - } else if (ctx.identification_variable() != null) { + public QueryTokenStream visitSelect_expression(JpqlParser.Select_expressionContext ctx) { - if (ctx.OBJECT() == null) { - return visit(ctx.identification_variable()); - } else { + if (ctx.identification_variable() != null && ctx.OBJECT() != null) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.OBJECT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.OBJECT())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.identification_variable())); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; - } - } else if (ctx.constructor_expression() != null) { - return visit(ctx.constructor_expression()); - } else { - return List.of(); + return builder; } - } - - @Override - public List visitConstructor_expression(JpqlParser.Constructor_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.NEW())); - tokens.addAll(visit(ctx.constructor_name())); - tokens.add(TOKEN_OPEN_PAREN); - - ctx.constructor_item().forEach(constructorItemContext -> { - tokens.addAll(visit(constructorItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - - tokens.add(TOKEN_CLOSE_PAREN); - return tokens; + return super.visitSelect_expression(ctx); } @Override - public List visitConstructor_item(JpqlParser.Constructor_itemContext ctx) { + public QueryTokenStream visitConstructor_expression(JpqlParser.Constructor_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_path_expression())); - } else if (ctx.scalar_expression() != null) { - tokens.addAll(visit(ctx.scalar_expression())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.literal() != null) { - tokens.addAll(visit(ctx.literal())); - } + builder.append(QueryTokens.expression(ctx.NEW())); + builder.append(visit(ctx.constructor_name())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(QueryTokenStream.concat(ctx.constructor_item(), this::visit, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitAggregate_expression(JpqlParser.Aggregate_expressionContext ctx) { + public QueryTokenStream visitAggregate_expression(JpqlParser.Aggregate_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.AVG() != null || ctx.MAX() != null || ctx.MIN() != null || ctx.SUM() != null) { if (ctx.AVG() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AVG(), false)); + builder.append(QueryTokens.token(ctx.AVG())); } if (ctx.MAX() != null) { - tokens.add(new JpaQueryParsingToken(ctx.MAX(), false)); + builder.append(QueryTokens.token(ctx.MAX())); } if (ctx.MIN() != null) { - tokens.add(new JpaQueryParsingToken(ctx.MIN(), false)); + builder.append(QueryTokens.token(ctx.MIN())); } if (ctx.SUM() != null) { - tokens.add(new JpaQueryParsingToken(ctx.SUM(), false)); + builder.append(QueryTokens.token(ctx.SUM())); } - tokens.add(TOKEN_OPEN_PAREN); + builder.append(TOKEN_OPEN_PAREN); if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); + builder.append(QueryTokens.expression(ctx.DISTINCT())); } - tokens.addAll(visit(ctx.state_valued_path_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + builder.appendInline(visit(ctx.simple_select_expression())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.COUNT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.COUNT(), false)); - tokens.add(TOKEN_OPEN_PAREN); + builder.append(QueryTokens.token(ctx.COUNT())); + builder.append(TOKEN_OPEN_PAREN); if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); - } - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); + builder.append(QueryTokens.expression(ctx.DISTINCT())); } - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); + + builder.appendInline(visit(ctx.simple_select_expression())); + builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); + builder.append(visit(ctx.function_invocation())); } - return tokens; + return builder; } @Override - public List visitWhere_clause(JpqlParser.Where_clauseContext ctx) { + public QueryTokenStream visitGroupby_clause(JpqlParser.Groupby_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.WHERE(), true)); - tokens.addAll(visit(ctx.conditional_expression())); + builder.append(QueryTokens.expression(ctx.GROUP())); + builder.append(QueryTokens.expression(ctx.BY())); + builder.appendExpression(QueryTokenStream.concat(ctx.groupby_item(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitGroupby_clause(JpqlParser.Groupby_clauseContext ctx) { + public QueryTokenStream visitOrderby_clause(JpqlParser.Orderby_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.GROUP())); - tokens.add(new JpaQueryParsingToken(ctx.BY())); - ctx.groupby_item().forEach(groupbyItemContext -> { - tokens.addAll(visit(groupbyItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); + builder.append(QueryTokens.expression(ctx.ORDER())); + builder.append(QueryTokens.expression(ctx.BY())); + builder.append(QueryTokenStream.concat(ctx.orderby_item(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitGroupby_item(JpqlParser.Groupby_itemContext ctx) { + public QueryTokenStream visitSubquery_from_clause(JpqlParser.Subquery_from_clauseContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_path_expression())); - } else if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } + builder.append(QueryTokens.expression(ctx.FROM())); + builder.appendExpression( + QueryTokenStream.concat(ctx.subselect_identification_variable_declaration(), this::visit, TOKEN_COMMA)); - return tokens; + return builder; } @Override - public List visitHaving_clause(JpqlParser.Having_clauseContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitConditional_primary(JpqlParser.Conditional_primaryContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.HAVING())); - tokens.addAll(visit(ctx.conditional_expression())); + if (ctx.conditional_expression() != null) { + return QueryTokenStream.group(visit(ctx.conditional_expression())); + } - return tokens; + return super.visitConditional_primary(ctx); } @Override - public List visitOrderby_clause(JpqlParser.Orderby_clauseContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.ORDER())); - tokens.add(new JpaQueryParsingToken(ctx.BY())); + public QueryTokenStream visitIn_expression(JpqlParser.In_expressionContext ctx) { - ctx.orderby_item().forEach(orderbyItemContext -> { - tokens.addAll(visit(orderbyItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + QueryRendererBuilder builder = QueryRenderer.builder(); - return tokens; - } - - @Override - public List visitOrderby_item(JpqlParser.Orderby_itemContext ctx) { + if (ctx.string_expression() != null) { + builder.appendExpression(visit(ctx.string_expression())); + } - List tokens = new ArrayList<>(); + if (ctx.type_discriminator() != null) { + builder.appendExpression(visit(ctx.type_discriminator())); + } - if (ctx.state_field_path_expression() != null) { - tokens.addAll(visit(ctx.state_field_path_expression())); - } else if (ctx.general_identification_variable() != null) { - tokens.addAll(visit(ctx.general_identification_variable())); - } else if (ctx.result_variable() != null) { - tokens.addAll(visit(ctx.result_variable())); + if (ctx.NOT() != null) { + builder.append(QueryTokens.expression(ctx.NOT())); } - if (ctx.ASC() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ASC())); + if (ctx.IN() != null) { + builder.append(QueryTokens.expression(ctx.IN())); } - if (ctx.DESC() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DESC())); + + if (ctx.in_item() != null && !ctx.in_item().isEmpty()) { + builder.append(QueryTokenStream.group(QueryTokenStream.concat(ctx.in_item(), this::visit, TOKEN_COMMA))); + } else if (ctx.subquery() != null) { + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); + } else if (ctx.collection_valued_input_parameter() != null) { + builder.append(visit(ctx.collection_valued_input_parameter())); } - return tokens; + return builder; } @Override - public List visitSubquery(JpqlParser.SubqueryContext ctx) { + public QueryTokenStream visitExists_expression(JpqlParser.Exists_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.addAll(visit(ctx.simple_select_clause())); - tokens.addAll(visit(ctx.subquery_from_clause())); - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); - } - if (ctx.groupby_clause() != null) { - tokens.addAll(visit(ctx.groupby_clause())); - } - if (ctx.having_clause() != null) { - tokens.addAll(visit(ctx.having_clause())); + if (ctx.NOT() != null) { + builder.append(QueryTokens.expression(ctx.NOT())); } - return tokens; + builder.append(QueryTokens.expression(ctx.EXISTS())); + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); + + return builder; } @Override - public List visitSubquery_from_clause(JpqlParser.Subquery_from_clauseContext ctx) { + public QueryTokenStream visitAll_or_any_expression(JpqlParser.All_or_any_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - ctx.subselect_identification_variable_declaration().forEach(subselectIdentificationVariableDeclarationContext -> { - tokens.addAll(visit(subselectIdentificationVariableDeclarationContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - SPACE(tokens); + if (ctx.ALL() != null) { + builder.append(QueryTokens.expression(ctx.ALL())); + } else if (ctx.ANY() != null) { + builder.append(QueryTokens.expression(ctx.ANY())); + } else if (ctx.SOME() != null) { + builder.append(QueryTokens.expression(ctx.SOME())); + } - return tokens; - } + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); - @Override - public List visitSubselect_identification_variable_declaration( - JpqlParser.Subselect_identification_variable_declarationContext ctx) { - return super.visitSubselect_identification_variable_declaration(ctx); + return builder; } @Override - public List visitDerived_path_expression(JpqlParser.Derived_path_expressionContext ctx) { - return super.visitDerived_path_expression(ctx); - } + public QueryTokenStream visitArithmetic_factor(JpqlParser.Arithmetic_factorContext ctx) { - @Override - public List visitGeneral_derived_path(JpqlParser.General_derived_pathContext ctx) { - return super.visitGeneral_derived_path(ctx); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - @Override - public List visitSimple_derived_path(JpqlParser.Simple_derived_pathContext ctx) { - return super.visitSimple_derived_path(ctx); - } + if (ctx.op != null) { + builder.append(QueryTokens.token(ctx.op)); + } - @Override - public List visitTreated_derived_path(JpqlParser.Treated_derived_pathContext ctx) { - return super.visitTreated_derived_path(ctx); + builder.append(visit(ctx.arithmetic_primary())); + + return builder; } @Override - public List visitDerived_collection_member_declaration( - JpqlParser.Derived_collection_member_declarationContext ctx) { - return super.visitDerived_collection_member_declaration(ctx); + public QueryTokenStream visitArithmetic_primary(JpqlParser.Arithmetic_primaryContext ctx) { + + if (ctx.arithmetic_expression() != null) { + return QueryTokenStream.group(visit(ctx.arithmetic_expression())); + } else if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); + } + + return super.visitArithmetic_primary(ctx); } @Override - public List visitSimple_select_clause(JpqlParser.Simple_select_clauseContext ctx) { + public QueryTokenStream visitString_expression(JpqlParser.String_expressionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.SELECT())); - if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - tokens.addAll(visit(ctx.simple_select_expression())); - return tokens; + return super.visitString_expression(ctx); } @Override - public List visitSimple_select_expression(JpqlParser.Simple_select_expressionContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitDatetime_expression(JpqlParser.Datetime_expressionContext ctx) { - if (ctx.single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_path_expression())); - } else if (ctx.scalar_expression() != null) { - tokens.addAll(visit(ctx.scalar_expression())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return tokens; + return super.visitDatetime_expression(ctx); } @Override - public List visitScalar_expression(JpqlParser.Scalar_expressionContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitBoolean_expression(JpqlParser.Boolean_expressionContext ctx) { - if (ctx.arithmetic_expression() != null) { - tokens.addAll(visit(ctx.arithmetic_expression())); - } else if (ctx.string_expression() != null) { - tokens.addAll(visit(ctx.string_expression())); - } else if (ctx.enum_expression() != null) { - tokens.addAll(visit(ctx.enum_expression())); - } else if (ctx.datetime_expression() != null) { - tokens.addAll(visit(ctx.datetime_expression())); - } else if (ctx.boolean_expression() != null) { - tokens.addAll(visit(ctx.boolean_expression())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.entity_type_expression() != null) { - tokens.addAll(visit(ctx.entity_type_expression())); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return tokens; + return super.visitBoolean_expression(ctx); } @Override - public List visitConditional_expression(JpqlParser.Conditional_expressionContext ctx) { - - List tokens = new ArrayList<>(); + public QueryTokenStream visitEnum_expression(JpqlParser.Enum_expressionContext ctx) { - if (ctx.conditional_expression() != null) { - tokens.addAll(visit(ctx.conditional_expression())); - tokens.add(new JpaQueryParsingToken(ctx.OR())); - tokens.addAll(visit(ctx.conditional_term())); - } else { - tokens.addAll(visit(ctx.conditional_term())); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return tokens; + return super.visitEnum_expression(ctx); } @Override - public List visitConditional_term(JpqlParser.Conditional_termContext ctx) { + public QueryTokenStream visitType_discriminator(JpqlParser.Type_discriminatorContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.conditional_term() != null) { - tokens.addAll(visit(ctx.conditional_term())); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.conditional_factor())); - } else { - tokens.addAll(visit(ctx.conditional_factor())); + if (ctx.general_identification_variable() != null) { + builder.append(visit(ctx.general_identification_variable())); + } else if (ctx.single_valued_object_path_expression() != null) { + builder.append(visit(ctx.single_valued_object_path_expression())); + } else if (ctx.input_parameter() != null) { + builder.append(visit(ctx.input_parameter())); } - return tokens; + return QueryTokenStream.ofFunction(ctx.TYPE(), builder); } @Override - public List visitConditional_factor(JpqlParser.Conditional_factorContext ctx) { + public QueryTokenStream visitFunctions_returning_numerics(JpqlParser.Functions_returning_numericsContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } + if (ctx.LENGTH() != null) { + return QueryTokenStream.ofFunction(ctx.LENGTH(), visit(ctx.string_expression(0))); + } else if (ctx.LOCATE() != null) { - JpqlParser.Conditional_primaryContext conditionalPrimary = ctx.conditional_primary(); - List visitedConditionalPrimary = visit(conditionalPrimary); - tokens.addAll(visitedConditionalPrimary); + builder.appendInline(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.string_expression(1))); - return tokens; - } + if (ctx.arithmetic_expression() != null) { + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(0))); + } - @Override - public List visitConditional_primary(JpqlParser.Conditional_primaryContext ctx) { + return QueryTokenStream.ofFunction(ctx.LOCATE(), builder); + } else if (ctx.ABS() != null) { + return QueryTokenStream.ofFunction(ctx.ABS(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.CEILING() != null) { + return QueryTokenStream.ofFunction(ctx.CEILING(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.EXP() != null) { + return QueryTokenStream.ofFunction(ctx.EXP(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.FLOOR() != null) { + return QueryTokenStream.ofFunction(ctx.FLOOR(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.LN() != null) { + return QueryTokenStream.ofFunction(ctx.LN(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.SIGN() != null) { + return QueryTokenStream.ofFunction(ctx.SIGN(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.SQRT() != null) { + return QueryTokenStream.ofFunction(ctx.SQRT(), visit(ctx.arithmetic_expression(0))); + } else if (ctx.MOD() != null) { - List tokens = new ArrayList<>(); + builder.appendInline(visit(ctx.arithmetic_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(1))); - if (ctx.simple_cond_expression() != null) { - tokens.addAll(visit(ctx.simple_cond_expression())); - } else if (ctx.conditional_expression() != null) { + return QueryTokenStream.ofFunction(ctx.MOD(), builder); + } else if (ctx.POWER() != null) { - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.conditional_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } + builder.appendInline(visit(ctx.arithmetic_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(1))); - return tokens; - } + return QueryTokenStream.ofFunction(ctx.POWER(), builder); + } else if (ctx.ROUND() != null) { - @Override - public List visitSimple_cond_expression(JpqlParser.Simple_cond_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.comparison_expression() != null) { - tokens.addAll(visit(ctx.comparison_expression())); - } else if (ctx.between_expression() != null) { - tokens.addAll(visit(ctx.between_expression())); - } else if (ctx.in_expression() != null) { - tokens.addAll(visit(ctx.in_expression())); - } else if (ctx.like_expression() != null) { - tokens.addAll(visit(ctx.like_expression())); - } else if (ctx.null_comparison_expression() != null) { - tokens.addAll(visit(ctx.null_comparison_expression())); - } else if (ctx.empty_collection_comparison_expression() != null) { - tokens.addAll(visit(ctx.empty_collection_comparison_expression())); - } else if (ctx.collection_member_expression() != null) { - tokens.addAll(visit(ctx.collection_member_expression())); - } else if (ctx.exists_expression() != null) { - tokens.addAll(visit(ctx.exists_expression())); + builder.appendInline(visit(ctx.arithmetic_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(1))); + + return QueryTokenStream.ofFunction(ctx.ROUND(), builder); + } else if (ctx.SIZE() != null) { + return QueryTokenStream.ofFunction(ctx.SIZE(), visit(ctx.collection_valued_path_expression())); + } else if (ctx.INDEX() != null) { + return QueryTokenStream.ofFunction(ctx.INDEX(), visit(ctx.identification_variable())); + } else if (ctx.extract_datetime_field() != null) { + builder.append(visit(ctx.extract_datetime_field())); } - return tokens; + return builder; } @Override - public List visitBetween_expression(JpqlParser.Between_expressionContext ctx) { + public QueryTokenStream visitFunctions_returning_strings(JpqlParser.Functions_returning_stringsContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.arithmetic_expression(0) != null) { + if (ctx.CONCAT() != null) { + return QueryTokenStream.ofFunction(ctx.CONCAT(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); + } else if (ctx.SUBSTRING() != null) { - tokens.addAll(visit(ctx.arithmetic_expression(0))); + builder.append(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(QueryTokenStream.concat(ctx.arithmetic_expression(), this::visit, TOKEN_COMMA)); + + return QueryTokenStream.ofFunction(ctx.SUBSTRING(), builder); + } else if (ctx.TRIM() != null) { - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); + if (ctx.trim_specification() != null) { + builder.appendExpression(visit(ctx.trim_specification())); + } + if (ctx.trim_character() != null) { + builder.appendExpression(visit(ctx.trim_character())); + } + if (ctx.FROM() != null) { + builder.append(QueryTokens.expression(ctx.FROM())); } - tokens.add(new JpaQueryParsingToken(ctx.BETWEEN())); - tokens.addAll(visit(ctx.arithmetic_expression(1))); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.arithmetic_expression(2))); + builder.append(visit(ctx.string_expression(0))); - } else if (ctx.string_expression(0) != null) { + return QueryTokenStream.ofFunction(ctx.TRIM(), builder); + } else if (ctx.LOWER() != null) { + return QueryTokenStream.ofFunction(ctx.LOWER(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); + } else if (ctx.UPPER() != null) { + return QueryTokenStream.ofFunction(ctx.UPPER(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); + } else if (ctx.LEFT() != null) { - tokens.addAll(visit(ctx.string_expression(0))); + builder.append(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.arithmetic_expression(0))); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } + return QueryTokenStream.ofFunction(ctx.LEFT(), builder); + } else if (ctx.RIGHT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.BETWEEN())); - tokens.addAll(visit(ctx.string_expression(1))); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.string_expression(2))); + builder.appendInline(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.arithmetic_expression(0))); - } else if (ctx.datetime_expression(0) != null) { + return QueryTokenStream.ofFunction(ctx.RIGHT(), builder); + } else if (ctx.REPLACE() != null) { + return QueryTokenStream.ofFunction(ctx.REPLACE(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); + } - tokens.addAll(visit(ctx.datetime_expression(0))); + return builder; + } - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } + @Override + public QueryTokenStream visitArithmetic_cast_function(JpqlParser.Arithmetic_cast_functionContext ctx) { - tokens.add(new JpaQueryParsingToken(ctx.BETWEEN())); - tokens.addAll(visit(ctx.datetime_expression(1))); - tokens.add(new JpaQueryParsingToken(ctx.AND())); - tokens.addAll(visit(ctx.datetime_expression(2))); + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.appendExpression(visit(ctx.string_expression())); + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); } + builder.append(QueryTokens.token(ctx.f)); - return tokens; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override - public List visitIn_expression(JpqlParser.In_expressionContext ctx) { + public QueryTokenStream visitType_cast_function(JpqlParser.Type_cast_functionContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } - if (ctx.type_discriminator() != null) { - tokens.addAll(visit(ctx.type_discriminator())); - } - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - if (ctx.IN() != null) { - tokens.add(new JpaQueryParsingToken(ctx.IN())); + builder.appendExpression(visit(ctx.scalar_expression())); + + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); } - if (ctx.in_item() != null && !ctx.in_item().isEmpty()) { + builder.appendInline(visit(ctx.identification_variable())); + + if (!CollectionUtils.isEmpty(ctx.numeric_literal())) { - tokens.add(TOKEN_OPEN_PAREN); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(QueryTokenStream.concat(ctx.numeric_literal(), this::visit, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_PAREN); + } - ctx.in_item().forEach(inItemContext -> { + return QueryTokenStream.ofFunction(ctx.CAST(), builder); + } - tokens.addAll(visit(inItemContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); + @Override + public QueryTokenStream visitString_cast_function(JpqlParser.String_cast_functionContext ctx) { - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.subquery() != null) { + QueryRendererBuilder builder = QueryRenderer.builder(); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.collection_valued_input_parameter() != null) { - tokens.addAll(visit(ctx.collection_valued_input_parameter())); + builder.appendExpression(visit(ctx.scalar_expression())); + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); } + builder.append(QueryTokens.token(ctx.STRING())); - return tokens; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override - public List visitIn_item(JpqlParser.In_itemContext ctx) { + public QueryTokenStream visitFunction_invocation(JpqlParser.Function_invocationContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.literal() != null) { - tokens.addAll(visit(ctx.literal())); - } else if (ctx.single_valued_input_parameter() != null) { - tokens.addAll(visit(ctx.single_valued_input_parameter())); + builder.append(QueryTokens.token(ctx.FUNCTION())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.function_name())); + if (!ctx.function_arg().isEmpty()) { + builder.append(TOKEN_COMMA); + builder.appendInline(QueryTokenStream.concat(ctx.function_arg(), this::visit, TOKEN_COMMA)); } + builder.append(TOKEN_CLOSE_PAREN); - return tokens; + return builder; } @Override - public List visitLike_expression(JpqlParser.Like_expressionContext ctx) { + public QueryTokenStream visitExtract_datetime_field(JpqlParser.Extract_datetime_fieldContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder nested = QueryRenderer.builder(); - tokens.addAll(visit(ctx.string_expression())); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - tokens.add(new JpaQueryParsingToken(ctx.LIKE())); - tokens.addAll(visit(ctx.pattern_value())); + nested.appendExpression(visit(ctx.datetime_field())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.datetime_expression())); - if (ctx.ESCAPE() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.ESCAPE())); - tokens.addAll(visit(ctx.escape_character())); - } - - return tokens; - } - - @Override - public List visitNull_comparison_expression(JpqlParser.Null_comparison_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.single_valued_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_path_expression())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } - - tokens.add(new JpaQueryParsingToken(ctx.IS())); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - tokens.add(new JpaQueryParsingToken(ctx.NULL())); - - return tokens; - } - - @Override - public List visitEmpty_collection_comparison_expression( - JpqlParser.Empty_collection_comparison_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.collection_valued_path_expression())); - tokens.add(new JpaQueryParsingToken(ctx.IS())); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - tokens.add(new JpaQueryParsingToken(ctx.EMPTY())); - - return tokens; - } - - @Override - public List visitCollection_member_expression( - JpqlParser.Collection_member_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.entity_or_value_expression())); - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - tokens.add(new JpaQueryParsingToken(ctx.MEMBER())); - if (ctx.OF() != null) { - tokens.add(new JpaQueryParsingToken(ctx.OF())); - } - tokens.addAll(visit(ctx.collection_valued_path_expression())); - - return tokens; - } - - @Override - public List visitEntity_or_value_expression(JpqlParser.Entity_or_value_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); - } else if (ctx.state_field_path_expression() != null) { - tokens.addAll(visit(ctx.state_field_path_expression())); - } else if (ctx.simple_entity_or_value_expression() != null) { - tokens.addAll(visit(ctx.simple_entity_or_value_expression())); - } - - return tokens; - } - - @Override - public List visitSimple_entity_or_value_expression( - JpqlParser.Simple_entity_or_value_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.literal() != null) { - tokens.addAll(visit(ctx.literal())); - } - - return tokens; - } - - @Override - public List visitExists_expression(JpqlParser.Exists_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.NOT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.NOT())); - } - tokens.add(new JpaQueryParsingToken(ctx.EXISTS())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitAll_or_any_expression(JpqlParser.All_or_any_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.ALL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ALL())); - } else if (ctx.ANY() != null) { - tokens.add(new JpaQueryParsingToken(ctx.ANY())); - } else if (ctx.SOME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.SOME())); - } - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitComparison_expression(JpqlParser.Comparison_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (!ctx.string_expression().isEmpty()) { - - tokens.addAll(visit(ctx.string_expression(0))); - tokens.addAll(visit(ctx.comparison_operator())); - - if (ctx.string_expression(1) != null) { - tokens.addAll(visit(ctx.string_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - } else if (!ctx.boolean_expression().isEmpty()) { - - tokens.addAll(visit(ctx.boolean_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - - if (ctx.boolean_expression(1) != null) { - tokens.addAll(visit(ctx.boolean_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - } else if (!ctx.enum_expression().isEmpty()) { - - tokens.addAll(visit(ctx.enum_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - - if (ctx.enum_expression(1) != null) { - tokens.addAll(visit(ctx.enum_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - } else if (!ctx.datetime_expression().isEmpty()) { - - tokens.addAll(visit(ctx.datetime_expression(0))); - tokens.addAll(visit(ctx.comparison_operator())); - - if (ctx.datetime_expression(1) != null) { - tokens.addAll(visit(ctx.datetime_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - } else if (!ctx.entity_expression().isEmpty()) { - - tokens.addAll(visit(ctx.entity_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - - if (ctx.entity_expression(1) != null) { - tokens.addAll(visit(ctx.entity_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - } else if (!ctx.arithmetic_expression().isEmpty()) { - - tokens.addAll(visit(ctx.arithmetic_expression(0))); - tokens.addAll(visit(ctx.comparison_operator())); - - if (ctx.arithmetic_expression(1) != null) { - tokens.addAll(visit(ctx.arithmetic_expression(1))); - } else { - tokens.addAll(visit(ctx.all_or_any_expression())); - } - } else if (!ctx.entity_type_expression().isEmpty()) { - - tokens.addAll(visit(ctx.entity_type_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.op)); - tokens.addAll(visit(ctx.entity_type_expression(1))); - } - - return tokens; - } - - @Override - public List visitComparison_operator(JpqlParser.Comparison_operatorContext ctx) { - return List.of(new JpaQueryParsingToken(ctx.op)); - } - - @Override - public List visitArithmetic_expression(JpqlParser.Arithmetic_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.arithmetic_expression() != null) { - - tokens.addAll(visit(ctx.arithmetic_expression())); - tokens.add(new JpaQueryParsingToken(ctx.op)); - tokens.addAll(visit(ctx.arithmetic_term())); - - } else { - tokens.addAll(visit(ctx.arithmetic_term())); - } - - return tokens; - } - - @Override - public List visitArithmetic_term(JpqlParser.Arithmetic_termContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.arithmetic_term() != null) { - - tokens.addAll(visit(ctx.arithmetic_term())); - NOSPACE(tokens); - tokens.add(new JpaQueryParsingToken(ctx.op, false)); - tokens.addAll(visit(ctx.arithmetic_factor())); - } else { - tokens.addAll(visit(ctx.arithmetic_factor())); - } - - return tokens; - } - - @Override - public List visitArithmetic_factor(JpqlParser.Arithmetic_factorContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.op != null) { - tokens.add(new JpaQueryParsingToken(ctx.op, false)); - } - tokens.addAll(visit(ctx.arithmetic_primary())); - - return tokens; - } - - @Override - public List visitArithmetic_primary(JpqlParser.Arithmetic_primaryContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.numeric_literal() != null) { - tokens.addAll(visit(ctx.numeric_literal())); - } else if (ctx.arithmetic_expression() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_numerics() != null) { - tokens.addAll(visit(ctx.functions_returning_numerics())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitString_expression(JpqlParser.String_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.string_literal() != null) { - tokens.addAll(visit(ctx.string_literal())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_strings() != null) { - tokens.addAll(visit(ctx.functions_returning_strings())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitDatetime_expression(JpqlParser.Datetime_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_datetime() != null) { - tokens.addAll(visit(ctx.functions_returning_datetime())); - } else if (ctx.aggregate_expression() != null) { - tokens.addAll(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); - } else if (ctx.date_time_timestamp_literal() != null) { - tokens.addAll(visit(ctx.date_time_timestamp_literal())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitBoolean_expression(JpqlParser.Boolean_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.boolean_literal() != null) { - tokens.addAll(visit(ctx.boolean_literal())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - tokens.addAll(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitEnum_expression(JpqlParser.Enum_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.state_valued_path_expression() != null) { - tokens.addAll(visit(ctx.state_valued_path_expression())); - } else if (ctx.enum_literal() != null) { - tokens.addAll(visit(ctx.enum_literal())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } else if (ctx.case_expression() != null) { - tokens.addAll(visit(ctx.case_expression())); - } else if (ctx.subquery() != null) { - - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.subquery())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitEntity_expression(JpqlParser.Entity_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); - } else if (ctx.simple_entity_expression() != null) { - tokens.addAll(visit(ctx.simple_entity_expression())); - } - - return tokens; - } - - @Override - public List visitSimple_entity_expression(JpqlParser.Simple_entity_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.identification_variable() != null) { - tokens.addAll(visit(ctx.identification_variable())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } - - return tokens; - } - - @Override - public List visitEntity_type_expression(JpqlParser.Entity_type_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.type_discriminator() != null) { - tokens.addAll(visit(ctx.type_discriminator())); - } else if (ctx.entity_type_literal() != null) { - tokens.addAll(visit(ctx.entity_type_literal())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } - - return tokens; - } - - @Override - public List visitType_discriminator(JpqlParser.Type_discriminatorContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.TYPE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - - if (ctx.general_identification_variable() != null) { - tokens.addAll(visit(ctx.general_identification_variable())); - } else if (ctx.single_valued_object_path_expression() != null) { - tokens.addAll(visit(ctx.single_valued_object_path_expression())); - } else if (ctx.input_parameter() != null) { - tokens.addAll(visit(ctx.input_parameter())); - } - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitFunctions_returning_numerics( - JpqlParser.Functions_returning_numericsContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.LENGTH() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.LENGTH(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.LOCATE() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.LOCATE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.string_expression(1))); - NOSPACE(tokens); - if (ctx.arithmetic_expression() != null) { - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - } - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.ABS() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.ABS(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.CEILING() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.CEILING(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.EXP() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.EXP(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.FLOOR() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.FLOOR(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.LN() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.LN(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.SIGN() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.SIGN(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.SQRT() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.SQRT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.MOD() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.MOD(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.arithmetic_expression(1))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.POWER() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.POWER(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.arithmetic_expression(1))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.ROUND() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.ROUND(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.arithmetic_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.arithmetic_expression(1))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.SIZE() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.SIZE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.collection_valued_path_expression())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.INDEX() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.INDEX(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.identification_variable())); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitFunctions_returning_datetime( - JpqlParser.Functions_returning_datetimeContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.CURRENT_DATE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT_DATE())); - } else if (ctx.CURRENT_TIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT_TIME())); - } else if (ctx.CURRENT_TIMESTAMP() != null) { - tokens.add(new JpaQueryParsingToken(ctx.CURRENT_TIMESTAMP())); - } else if (ctx.LOCAL() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.LOCAL())); - - if (ctx.DATE() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DATE())); - } else if (ctx.TIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.TIME())); - } else if (ctx.DATETIME() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DATETIME())); - } - } - - return tokens; + return QueryTokenStream.ofFunction(ctx.EXTRACT(), nested); } @Override - public List visitFunctions_returning_strings( - JpqlParser.Functions_returning_stringsContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.CONCAT() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.CONCAT(), false)); - tokens.add(TOKEN_OPEN_PAREN); - ctx.string_expression().forEach(stringExpressionContext -> { - tokens.addAll(visit(stringExpressionContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.SUBSTRING() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.SUBSTRING(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - ctx.arithmetic_expression().forEach(arithmeticExpressionContext -> { - tokens.addAll(visit(arithmeticExpressionContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.TRIM() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.TRIM(), false)); - tokens.add(TOKEN_OPEN_PAREN); - if (ctx.trim_specification() != null) { - tokens.addAll(visit(ctx.trim_specification())); - } - if (ctx.trim_character() != null) { - tokens.addAll(visit(ctx.trim_character())); - } - if (ctx.FROM() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - } - tokens.addAll(visit(ctx.string_expression(0))); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.LOWER() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.LOWER(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else if (ctx.UPPER() != null) { - - tokens.add(new JpaQueryParsingToken(ctx.UPPER(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.string_expression(0))); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } - - return tokens; - } - - @Override - public List visitTrim_specification(JpqlParser.Trim_specificationContext ctx) { - - if (ctx.LEADING() != null) { - return List.of(new JpaQueryParsingToken(ctx.LEADING())); - } else if (ctx.TRAILING() != null) { - return List.of(new JpaQueryParsingToken(ctx.TRAILING())); - } else { - return List.of(new JpaQueryParsingToken(ctx.BOTH())); - } - } - - @Override - public List visitFunction_invocation(JpqlParser.Function_invocationContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.FUNCTION(), false)); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.function_name())); - NOSPACE(tokens); - ctx.function_arg().forEach(functionArgContext -> { - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(functionArgContext)); - NOSPACE(tokens); - }); - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitExtract_datetime_field(JpqlParser.Extract_datetime_fieldContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.EXTRACT())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.datetime_field())); - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - tokens.addAll(visit(ctx.datetime_expression())); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitDatetime_field(JpqlParser.Datetime_fieldContext ctx) { + public QueryTokenStream visitDatetime_field(JpqlParser.Datetime_fieldContext ctx) { return visit(ctx.identification_variable()); } @Override - public List visitExtract_datetime_part(JpqlParser.Extract_datetime_partContext ctx) { + public QueryTokenStream visitExtract_datetime_part(JpqlParser.Extract_datetime_partContext ctx) { - List tokens = new ArrayList<>(); + QueryRendererBuilder nested = QueryRenderer.builder(); - tokens.add(new JpaQueryParsingToken(ctx.EXTRACT())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.datetime_part())); - tokens.add(new JpaQueryParsingToken(ctx.FROM())); - tokens.addAll(visit(ctx.datetime_expression())); - tokens.add(TOKEN_CLOSE_PAREN); + nested.appendExpression(visit(ctx.datetime_part())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.datetime_expression())); - return tokens; + return QueryTokenStream.ofFunction(ctx.EXTRACT(), nested); } @Override - public List visitDatetime_part(JpqlParser.Datetime_partContext ctx) { - return visit(ctx.identification_variable()); - } + public QueryTokenStream visitCoalesce_expression(JpqlParser.Coalesce_expressionContext ctx) { - @Override - public List visitFunction_arg(JpqlParser.Function_argContext ctx) { - - if (ctx.literal() != null) { - return visit(ctx.literal()); - } else if (ctx.state_valued_path_expression() != null) { - return visit(ctx.state_valued_path_expression()); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } else { - return visit(ctx.scalar_expression()); - } - } - - @Override - public List visitCase_expression(JpqlParser.Case_expressionContext ctx) { - - if (ctx.general_case_expression() != null) { - return visit(ctx.general_case_expression()); - } else if (ctx.simple_case_expression() != null) { - return visit(ctx.simple_case_expression()); - } else if (ctx.coalesce_expression() != null) { - return visit(ctx.coalesce_expression()); - } else { - return visit(ctx.nullif_expression()); - } + return QueryTokenStream.ofFunction(ctx.COALESCE(), + QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); } @Override - public List visitGeneral_case_expression(JpqlParser.General_case_expressionContext ctx) { + public QueryTokenStream visitNullif_expression(JpqlParser.Nullif_expressionContext ctx) { - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.CASE())); - - ctx.when_clause().forEach(whenClauseContext -> { - tokens.addAll(visit(whenClauseContext)); - }); - - tokens.add(new JpaQueryParsingToken(ctx.ELSE())); - tokens.addAll(visit(ctx.scalar_expression())); - tokens.add(new JpaQueryParsingToken(ctx.END())); - - return tokens; + return QueryTokenStream.ofFunction(ctx.NULLIF(), + QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); } @Override - public List visitWhen_clause(JpqlParser.When_clauseContext ctx) { + public QueryTokenStream visitInput_parameter(JpqlParser.Input_parameterContext ctx) { - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.WHEN())); - tokens.addAll(visit(ctx.conditional_expression())); - tokens.add(new JpaQueryParsingToken(ctx.THEN())); - tokens.addAll(visit(ctx.scalar_expression())); - - return tokens; - } - - @Override - public List visitSimple_case_expression(JpqlParser.Simple_case_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.CASE())); - tokens.addAll(visit(ctx.case_operand())); - - ctx.simple_when_clause().forEach(simpleWhenClauseContext -> { - tokens.addAll(visit(simpleWhenClauseContext)); - }); - - tokens.add(new JpaQueryParsingToken(ctx.ELSE())); - tokens.addAll(visit(ctx.scalar_expression())); - tokens.add(new JpaQueryParsingToken(ctx.END())); - - return tokens; - } - - @Override - public List visitCase_operand(JpqlParser.Case_operandContext ctx) { - - if (ctx.state_valued_path_expression() != null) { - return visit(ctx.state_valued_path_expression()); - } else { - return visit(ctx.type_discriminator()); - } - } - - @Override - public List visitSimple_when_clause(JpqlParser.Simple_when_clauseContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.WHEN())); - tokens.addAll(visit(ctx.scalar_expression(0))); - tokens.add(new JpaQueryParsingToken(ctx.THEN())); - tokens.addAll(visit(ctx.scalar_expression(1))); - - return tokens; - } - - @Override - public List visitCoalesce_expression(JpqlParser.Coalesce_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.COALESCE(), false)); - tokens.add(TOKEN_OPEN_PAREN); - ctx.scalar_expression().forEach(scalarExpressionContext -> { - tokens.addAll(visit(scalarExpressionContext)); - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - }); - CLIP(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitNullif_expression(JpqlParser.Nullif_expressionContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.add(new JpaQueryParsingToken(ctx.NULLIF())); - tokens.add(TOKEN_OPEN_PAREN); - tokens.addAll(visit(ctx.scalar_expression(0))); - tokens.add(TOKEN_COMMA); - tokens.addAll(visit(ctx.scalar_expression(1))); - tokens.add(TOKEN_CLOSE_PAREN); - - return tokens; - } - - @Override - public List visitTrim_character(JpqlParser.Trim_characterContext ctx) { - - if (ctx.CHARACTER() != null) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); - } else if (ctx.character_valued_input_parameter() != null) { - return visit(ctx.character_valued_input_parameter()); - } else { - return List.of(); - } - } - - @Override - public List visitIdentification_variable(JpqlParser.Identification_variableContext ctx) { - - if (ctx.IDENTIFICATION_VARIABLE() != null) { - return List.of(new JpaQueryParsingToken(ctx.IDENTIFICATION_VARIABLE())); - } else if (ctx.f != null) { - return List.of(new JpaQueryParsingToken(ctx.f)); - } else { - return List.of(); - } - } - - @Override - public List visitConstructor_name(JpqlParser.Constructor_nameContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.entity_name())); - NOSPACE(tokens); - - return tokens; - } - - @Override - public List visitLiteral(JpqlParser.LiteralContext ctx) { - - List tokens = new ArrayList<>(); - - if (ctx.STRINGLITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.STRINGLITERAL())); - } else if (ctx.JAVASTRINGLITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.JAVASTRINGLITERAL())); - } else if (ctx.INTLITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.INTLITERAL())); - } else if (ctx.FLOATLITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.FLOATLITERAL())); - } else if(ctx.LONGLITERAL() != null) { - tokens.add(new JpaQueryParsingToken(ctx.LONGLITERAL())); - } else if (ctx.boolean_literal() != null) { - tokens.addAll(visit(ctx.boolean_literal())); - } else if (ctx.entity_type_literal() != null) { - tokens.addAll(visit(ctx.entity_type_literal())); - } - - return tokens; - } - - @Override - public List visitInput_parameter(JpqlParser.Input_parameterContext ctx) { - - List tokens = new ArrayList<>(); + QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.INTLITERAL() != null) { - tokens.add(TOKEN_QUESTION_MARK); - tokens.add(new JpaQueryParsingToken(ctx.INTLITERAL())); + builder.append(TOKEN_QUESTION_MARK); + builder.append(QueryTokens.token(ctx.INTLITERAL())); } else if (ctx.identification_variable() != null) { - tokens.add(TOKEN_COLON); - tokens.addAll(visit(ctx.identification_variable())); + builder.append(TOKEN_COLON); + builder.appendInline(visit(ctx.identification_variable())); } - return tokens; - } - - @Override - public List visitPattern_value(JpqlParser.Pattern_valueContext ctx) { - - List tokens = new ArrayList<>(); - - tokens.addAll(visit(ctx.string_expression())); - - return tokens; - } - - @Override - public List visitDate_time_timestamp_literal( - JpqlParser.Date_time_timestamp_literalContext ctx) { - return List.of(new JpaQueryParsingToken(ctx.STRINGLITERAL())); - } - - @Override - public List visitEntity_type_literal(JpqlParser.Entity_type_literalContext ctx) { - return visit(ctx.identification_variable()); + return builder; } @Override - public List visitEscape_character(JpqlParser.Escape_characterContext ctx) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); + public QueryTokenStream visitEntity_name(JpqlParser.Entity_nameContext ctx) { + return QueryTokenStream.concat(ctx.reserved_word(), this::visit, TOKEN_DOT); } @Override - public List visitNumeric_literal(JpqlParser.Numeric_literalContext ctx) { + public QueryTokenStream visitChildren(RuleNode node) { - if (ctx.INTLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.INTLITERAL())); - } else if (ctx.FLOATLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.FLOATLITERAL())); - } else if(ctx.LONGLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.LONGLITERAL())); - } else { - return List.of(); - } - } + int childCount = node.getChildCount(); - @Override - public List visitBoolean_literal(JpqlParser.Boolean_literalContext ctx) { - - if (ctx.TRUE() != null) { - return List.of(new JpaQueryParsingToken(ctx.TRUE())); - } else if (ctx.FALSE() != null) { - return List.of(new JpaQueryParsingToken(ctx.FALSE())); - } else { - return List.of(); + if (childCount == 1 && node.getChild(0) instanceof RuleContext t) { + return visit(t); } - } - - @Override - public List visitEnum_literal(JpqlParser.Enum_literalContext ctx) { - return visit(ctx.state_field_path_expression()); - } - @Override - public List visitString_literal(JpqlParser.String_literalContext ctx) { - - if (ctx.CHARACTER() != null) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); - } else if (ctx.STRINGLITERAL() != null) { - return List.of(new JpaQueryParsingToken(ctx.STRINGLITERAL())); - } else { - return List.of(); + if (childCount == 1 && node.getChild(0) instanceof TerminalNode t) { + return QueryTokens.token(t); } - } - - @Override - public List visitSingle_valued_embeddable_object_field( - JpqlParser.Single_valued_embeddable_object_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitSubtype(JpqlParser.SubtypeContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitCollection_valued_field(JpqlParser.Collection_valued_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitSingle_valued_object_field(JpqlParser.Single_valued_object_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitState_field(JpqlParser.State_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitCollection_value_field(JpqlParser.Collection_value_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitEntity_name(JpqlParser.Entity_nameContext ctx) { - - List tokens = new ArrayList<>(); - - ctx.reserved_word().forEach(ctx2 -> { - tokens.addAll(visitReserved_word(ctx2)); - NOSPACE(tokens); - tokens.add(TOKEN_DOT); - }); - CLIP(tokens); - SPACE(tokens); - return tokens; + return QueryTokenStream.concatExpressions(node, this::visit); } - @Override - public List visitResult_variable(JpqlParser.Result_variableContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitSuperquery_identification_variable( - JpqlParser.Superquery_identification_variableContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public List visitCollection_valued_input_parameter( - JpqlParser.Collection_valued_input_parameterContext ctx) { - return visit(ctx.input_parameter()); - } - - @Override - public List visitSingle_valued_input_parameter( - JpqlParser.Single_valued_input_parameterContext ctx) { - return visit(ctx.input_parameter()); - } - - @Override - public List visitFunction_name(JpqlParser.Function_nameContext ctx) { - return visit(ctx.string_literal()); - } - - @Override - public List visitCharacter_valued_input_parameter( - JpqlParser.Character_valued_input_parameterContext ctx) { - - if (ctx.CHARACTER() != null) { - return List.of(new JpaQueryParsingToken(ctx.CHARACTER())); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } else { - return List.of(); - } - } - - @Override - public List visitReserved_word(Reserved_wordContext ctx) { - if (ctx.IDENTIFICATION_VARIABLE() != null) { - return List.of(new JpaQueryParsingToken(ctx.IDENTIFICATION_VARIABLE())); - } else if (ctx.f != null) { - return List.of(new JpaQueryParsingToken(ctx.f)); - } else { - return List.of(); - } - } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryTransformer.java deleted file mode 100644 index 4c2f5f6c4e..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryTransformer.java +++ /dev/null @@ -1,245 +0,0 @@ -/* - * Copyright 2022-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 static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import org.springframework.data.domain.Sort; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; - -/** - * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed JPQL query. - * - * @author Greg Turnquist - * @since 3.1 - */ -class JpqlQueryTransformer extends JpqlQueryRenderer { - - // TODO: Separate input from result parameters, encapsulation... - private final Sort sort; - private final boolean countQuery; - - private final @Nullable String countProjection; - - private @Nullable String primaryFromAlias = null; - - private List projection = Collections.emptyList(); - private boolean projectionProcessed; - - private boolean hasConstructorExpression = false; - - private JpaQueryTransformerSupport transformerSupport; - - JpqlQueryTransformer() { - this(Sort.unsorted(), false, null); - } - - JpqlQueryTransformer(Sort sort) { - this(sort, false, null); - } - - JpqlQueryTransformer(boolean countQuery, @Nullable String countProjection) { - this(Sort.unsorted(), countQuery, countProjection); - } - - private JpqlQueryTransformer(Sort sort, boolean countQuery, @Nullable String countProjection) { - - Assert.notNull(sort, "Sort must not be null"); - - this.sort = sort; - this.countQuery = countQuery; - this.countProjection = countProjection; - this.transformerSupport = new JpaQueryTransformerSupport(); - } - - @Nullable - public String getAlias() { - return this.primaryFromAlias; - } - - public List getProjection() { - return this.projection; - } - - public boolean hasConstructorExpression() { - return this.hasConstructorExpression; - } - - @Override - public List visitSelect_statement(JpqlParser.Select_statementContext ctx) { - - List tokens = newArrayList(); - - tokens.addAll(visit(ctx.select_clause())); - tokens.addAll(visit(ctx.from_clause())); - - if (ctx.where_clause() != null) { - tokens.addAll(visit(ctx.where_clause())); - } - - if (ctx.groupby_clause() != null) { - tokens.addAll(visit(ctx.groupby_clause())); - } - - if (ctx.having_clause() != null) { - tokens.addAll(visit(ctx.having_clause())); - } - - if (!countQuery) { - - if (ctx.orderby_clause() != null) { - tokens.addAll(visit(ctx.orderby_clause())); - } - - if (sort.isSorted()) { - - if (ctx.orderby_clause() != null) { - - NOSPACE(tokens); - tokens.add(TOKEN_COMMA); - } else { - - SPACE(tokens); - tokens.add(TOKEN_ORDER_BY); - } - - tokens.addAll(transformerSupport.generateOrderByArguments(primaryFromAlias, sort)); - } - } - - return tokens; - } - - @Override - public List visitSelect_clause(JpqlParser.Select_clauseContext ctx) { - - List tokens = newArrayList(); - - tokens.add(new JpaQueryParsingToken(ctx.SELECT())); - - if (countQuery) { - tokens.add(TOKEN_COUNT_FUNC); - } - - if (ctx.DISTINCT() != null) { - tokens.add(new JpaQueryParsingToken(ctx.DISTINCT())); - } - - List selectItemTokens = newArrayList(); - - ctx.select_item().forEach(selectItemContext -> { - selectItemTokens.addAll(visit(selectItemContext)); - NOSPACE(selectItemTokens); - selectItemTokens.add(TOKEN_COMMA); - }); - CLIP(selectItemTokens); - SPACE(selectItemTokens); - - if (countQuery) { - - if (countProjection != null) { - tokens.add(new JpaQueryParsingToken(countProjection)); - } else { - - if (ctx.DISTINCT() != null) { - - List countSelection = QueryTransformers.filterCountSelection(selectItemTokens); - - if (countSelection.stream().anyMatch(jpqlToken -> jpqlToken.getToken().contains("new"))) { - // constructor - tokens.add(new JpaQueryParsingToken(() -> primaryFromAlias)); - } else { - // keep all the select items to distinct against - tokens.addAll(countSelection); - } - } else { - tokens.add(new JpaQueryParsingToken(() -> primaryFromAlias)); - } - } - - NOSPACE(tokens); - tokens.add(TOKEN_CLOSE_PAREN); - } else { - tokens.addAll(selectItemTokens); - } - - if (!projectionProcessed) { - projection = selectItemTokens; - projectionProcessed = true; - } - - return tokens; - } - - @Override - public List visitSelect_item(JpqlParser.Select_itemContext ctx) { - - List tokens = super.visitSelect_item(ctx); - - if (ctx.result_variable() != null) { - transformerSupport.registerAlias(tokens.get(tokens.size() - 1).getToken()); - } - - return tokens; - } - - @Override - public List visitRange_variable_declaration(JpqlParser.Range_variable_declarationContext ctx) { - - List tokens = newArrayList(); - - tokens.addAll(visit(ctx.entity_name())); - - if (ctx.AS() != null) { - tokens.add(new JpaQueryParsingToken(ctx.AS())); - } - - tokens.addAll(visit(ctx.identification_variable())); - - if (primaryFromAlias == null) { - primaryFromAlias = tokens.get(tokens.size() - 1).getToken(); - } - - return tokens; - } - - @Override - public List visitJoin(JpqlParser.JoinContext ctx) { - - List tokens = super.visitJoin(ctx); - - transformerSupport.registerAlias(tokens.get(tokens.size() - 1).getToken()); - - return tokens; - } - - @Override - public List visitConstructor_expression(JpqlParser.Constructor_expressionContext ctx) { - - hasConstructorExpression = true; - - return super.visitConstructor_expression(ctx); - } - - private static ArrayList newArrayList() { - return new ArrayList<>(); - } -} 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 new file mode 100644 index 0000000000..9a86eb4424 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java @@ -0,0 +1,194 @@ +/* + * 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.springframework.data.jpa.repository.query.QueryTokens.*; + +import java.util.List; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +import org.springframework.data.repository.query.ReturnedType; +import org.springframework.util.Assert; + +/** + * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed JPQL query by applying + * {@link Sort}. + * + * @author Greg Turnquist + * @author Mark Paluch + * @since 3.1 + */ +@SuppressWarnings("ConstantValue") +class JpqlSortedQueryTransformer extends JpqlQueryRenderer { + + private final JpaQueryTransformerSupport transformerSupport = new JpaQueryTransformerSupport(); + private final Sort sort; + private final @Nullable String primaryFromAlias; + private final @Nullable DtoProjectionTransformerDelegate dtoDelegate; + + JpqlSortedQueryTransformer(Sort sort, QueryInformation queryInformation, @Nullable ReturnedType returnedType) { + + Assert.notNull(sort, "Sort must not be null"); + Assert.notNull(queryInformation, "ParsedHqlQueryInformation must not be null"); + + this.sort = sort; + this.primaryFromAlias = queryInformation.getAlias(); + this.dtoDelegate = returnedType == null ? null : new DtoProjectionTransformerDelegate(returnedType); + } + + @Override + public QueryTokenStream visitSelectQuery(JpqlParser.SelectQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.appendExpression(visit(ctx.select_clause())); + builder.appendExpression(visit(ctx.from_clause())); + + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); + } + + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } + + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } + + if (ctx.set_fuction() != null) { + builder.appendExpression(visit(ctx.set_fuction())); + } else { + doVisitOrderBy(builder, ctx.orderby_clause()); + } + + return builder; + } + + @Override + public QueryTokenStream visitFromQuery(JpqlParser.FromQueryContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.appendExpression(visit(ctx.from_clause())); + + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); + } + + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } + + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } + + if (ctx.set_fuction() != null) { + builder.appendExpression(visit(ctx.set_fuction())); + } else { + doVisitOrderBy(builder, ctx.orderby_clause()); + } + + return builder; + } + + @Override + public QueryTokenStream visitSelect_clause(JpqlParser.Select_clauseContext ctx) { + + if (dtoDelegate == null) { + return super.visitSelect_clause(ctx); + } + + QueryRendererBuilder builder = prepareSelectClause(ctx); + + QueryTokenStream selectItems = QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA); + + if (dtoDelegate != null && dtoDelegate.canRewrite()) { + builder.append(dtoDelegate.getRewrittenSelectionList()); + } else { + builder.append(selectItems); + } + + return builder; + } + + @Override + public QueryTokenStream visitSelect_item(JpqlParser.Select_itemContext ctx) { + + QueryTokenStream tokens = super.visitSelect_item(ctx); + + if (ctx.result_variable() != null && !tokens.isEmpty()) { + transformerSupport.registerAlias(ctx.result_variable().getText()); + } + + return tokens; + } + + @Override + public QueryTokenStream visitSelect_expression(JpqlParser.Select_expressionContext ctx) { + + QueryTokenStream selectItem = super.visitSelect_expression(ctx); + + if (dtoDelegate != null && dtoDelegate.applyRewriting() && ctx.constructor_expression() == null) { + dtoDelegate.appendSelectItem(selectItem); + } + + return selectItem; + } + + @Override + public QueryTokenStream visitJoin(JpqlParser.JoinContext ctx) { + + QueryTokenStream tokens = super.visitJoin(ctx); + + if (ctx.identification_variable() != null) { + transformerSupport.registerAlias(ctx.identification_variable().getText()); + } + + return tokens; + } + + private void doVisitOrderBy(QueryRendererBuilder builder, JpqlParser.Orderby_clauseContext ctx) { + + if (ctx != null) { + QueryTokenStream existingOrder = visit(ctx); + if (sort.isSorted()) { + builder.appendInline(existingOrder); + } else { + builder.append(existingOrder); + } + } + + if (sort.isSorted()) { + + List sortBy = transformerSupport.orderBy(primaryFromAlias, sort); + + if (ctx != null) { + + QueryRendererBuilder extension = QueryRenderer.builder().append(TOKEN_COMMA).append(sortBy); + + builder.appendInline(extension); + } else { + builder.append(TOKEN_ORDER_BY); + builder.append(sortBy); + } + } + } +} 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..7f7028c2a3 --- /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.metamodel.Attribute; +import jakarta.persistence.metamodel.Bindable; +import jakarta.persistence.metamodel.ManagedType; +import jakarta.persistence.metamodel.Metamodel; + +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.mapping.PropertyPath; +import org.springframework.util.Assert; + +/** + * Utilities to create JPQL expressions, derived from {@link QueryUtils}. + * + * @author Mark Paluch + */ +class JpqlUtils { + + static JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, + Bindable from, PropertyPath property) { + return toExpressionRecursively(metamodel, source, from, property, false); + } + + static JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, + Bindable from, PropertyPath property, boolean isForSelection) { + return JpqlExpressionFactory.INSTANCE.toExpressionRecursively(metamodel, source, from, property, isForSelection, + false); + } + + /** + * Expression Factory for JPQL queries that operate on String-based queries. + */ + static class JpqlExpressionFactory extends ExpressionFactorySupport { + + private static final JpqlExpressionFactory INSTANCE = new JpqlExpressionFactory(); + + /** + * Creates an expression with proper inner and left joins by recursively navigating the path + * + * @param metamodel the JPA {@link Metamodel} used to resolve attribute types to {@link ManagedType}. + * @param source the {@link org.springframework.data.jpa.repository.query.JpqlQueryBuilder.Origin} + * @param from bindable from which the property is navigated. + * @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 + */ + public JpqlQueryBuilder.PathExpression toExpressionRecursively(Metamodel metamodel, JpqlQueryBuilder.Origin source, + Bindable from, PropertyPath property, boolean isForSelection, boolean hasRequiredOuterJoin) { + + String segment = property.getSegment(); + + boolean isLeafProperty = !property.hasNext(); + BindablePathResolver resolver = new BindablePathResolver(metamodel, from); + boolean isRelationshipId = isRelationshipId(resolver, property); + boolean requiresOuterJoin = requiresOuterJoin(resolver, property, isForSelection, hasRequiredOuterJoin, + isLeafProperty, isRelationshipId); + + // if it does not require an outer join and is a leaf, simply get the segment + if (!requiresOuterJoin && (isLeafProperty || isRelationshipId)) { + 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 = 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); + } + + private static @Nullable Attribute getModelForPath(@Nullable Metamodel metamodel, PropertyPath path, + @Nullable ManagedType managedType, @Nullable 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 && fallback != null) { + + Class fallbackType = fallback.getBindableJavaType(); + try { + return metamodel.managedType(fallbackType).getAttribute(segment); + } catch (IllegalArgumentException e) { + // nothing to do here + } + } + + return null; + } + + record BindablePathResolver(Metamodel metamodel, + Bindable bindable) implements ExpressionFactorySupport.ModelPathResolver { + + @Override + public @Nullable Bindable resolve(PropertyPath propertyPath) { + + Attribute attribute = resolveAttribute(propertyPath); + return attribute instanceof Bindable b ? b : null; + } + + private @Nullable Attribute resolveAttribute(PropertyPath propertyPath) { + ManagedType managedType = getManagedTypeForModel(bindable); + return getModelForPath(metamodel, propertyPath, managedType, bindable); + } + + @Override + @SuppressWarnings("NullAway") + public @Nullable Bindable resolveNext(PropertyPath propertyPath) { + + Assert.state(propertyPath.hasNext(), "PropertyPath must contain at least one element"); + + Attribute propertyPathModel = resolveAttribute(propertyPath); + ManagedType propertyPathManagedType = getManagedTypeForModel(propertyPathModel); + Attribute next = getModelForPath(metamodel, Objects.requireNonNull(propertyPath.next()), + propertyPathManagedType, null); + + return next instanceof Bindable b ? b : 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 2942fa0bce..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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -16,15 +16,19 @@ package org.springframework.data.jpa.repository.query; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.LinkedHashSet; 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.lang.Nullable; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; /** * Delegate for keyset scrolling. @@ -47,8 +51,26 @@ public static KeysetScrollDelegate of(Direction direction) { return direction == Direction.FORWARD ? FORWARD : REVERSE; } - @Nullable - public P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStrategy strategy) { + /** + * Return a collection of property names required to construct a keyset selection query that include all keyset and + * identifier properties required to resume keyset scrolling. + * + * @param entity the underlying entity. + * @param projectionProperties projection property names. + * @param sort sort properties. + * @return a collection of property names required to construct a keyset selection query + */ + public static Collection getProjectionInputProperties(JpaEntityInformation entity, + Collection projectionProperties, Sort sort) { + + Collection properties = new LinkedHashSet<>(projectionProperties); + sort.forEach(it -> properties.add(it.getProperty())); + properties.addAll(entity.getIdAttributeNames()); + + return properties; + } + + public @Nullable P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStrategy strategy) { Map keysetValues = keyset.getKeys(); @@ -82,7 +104,7 @@ public P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStr break; } - sortConstraint.add(strategy.compare(propertyExpression, o)); + sortConstraint.add(strategy.compare(inner.getProperty(), propertyExpression, o)); j++; } @@ -112,8 +134,31 @@ protected List getResultWindow(List list, int limit) { return CollectionUtils.getFirst(limit, list); } + public Sort createSort(Sort sort, JpaEntityInformation entity) { + + Collection 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. */ @@ -162,19 +207,20 @@ public interface QueryStrategy { * * @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. @@ -182,7 +228,7 @@ public interface QueryStrategy { * @param intermediate the predicates to combine. Must not be {@literal null}. * @return a single predicate. */ - P and(List

intermediate); + @Nullable P and(List

intermediate); /** * OR-combine the {@code intermediate} predicates. @@ -190,7 +236,7 @@ public interface QueryStrategy { * @param intermediate the predicates to combine. Must not be {@literal null}. * @return a single predicate. */ - P or(List

intermediate); + @Nullable P or(List

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 6047c164ca..80986a5e5e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -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 (KeysetScrollPosition position, Sort sort, +public record KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEntityInformation entity) implements Specification { public KeysetScrollSpecification(KeysetScrollPosition position, Sort sort, JpaEntityInformation entity) { @@ -63,45 +64,36 @@ public static Sort createSort(KeysetScrollPosition position, Sort sort, JpaEntit KeysetScrollDelegate delegate = KeysetScrollDelegate.of(position.getDirection()); - Collection 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 root, CriteriaQuery query, CriteriaBuilder criteriaBuilder) { + public @Nullable Predicate toPredicate(Root 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(Metamodel metamodel, 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(metamodel, from, entity, factory)); } @SuppressWarnings("rawtypes") - private static class JpaQueryStrategy implements QueryStrategy, Predicate> { + private static class CriteriaBuilderStrategy implements QueryStrategy, 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 +107,18 @@ public Expression createExpression(String property) { } @Override - public Predicate compare(Order order, Expression propertyExpression, Object value) { + public Predicate compare(Order order, Expression 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 propertyExpression, @Nullable Object value) { + public Predicate compare(String property, Expression propertyExpression, @Nullable Object value) { return value == null ? cb.isNull(propertyExpression) : cb.equal(propertyExpression, value); } @@ -136,4 +132,63 @@ public Predicate or(List intermediate) { return cb.or(intermediate.toArray(new Predicate[0])); } } + + private static class JpqlStrategy implements QueryStrategy { + + private final Bindable from; + private final JpqlQueryBuilder.Entity entity; + private final ParameterFactory factory; + private final Metamodel metamodel; + + public JpqlStrategy(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 intermediate) { + return JpqlQueryBuilder.and(intermediate); + } + + @Override + public JpqlQueryBuilder.@Nullable Predicate or(List 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 79b6d7e0bd..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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -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 getValue(String key) { + private @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 5b665c287d..2eaaa0ef87 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -22,13 +22,19 @@ 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; import org.springframework.data.jpa.provider.QueryExtractor; +import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.repository.query.Parameters; import org.springframework.data.repository.query.QueryCreationException; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.repository.query.ReturnedType; -import org.springframework.lang.Nullable; +import org.springframework.data.util.Lazy; +import org.springframework.util.StringUtils; /** * Implementation of {@link RepositoryQuery} based on {@link jakarta.persistence.NamedQuery}s. @@ -50,13 +56,13 @@ final class NamedQuery extends AbstractJpaQuery { private final String countQueryName; private final @Nullable String countProjection; private final boolean namedCountQueryIsPresent; - private final DeclaredQuery declaredQuery; - private final QueryParameterSetter.QueryMetadataCache metadataCache; + private final Lazy entityQuery; + private final QueryRewriter queryRewriter; /** * Creates a new {@link NamedQuery}. */ - private NamedQuery(JpaQueryMethod method, EntityManager em) { + private NamedQuery(JpaQueryMethod method, EntityManager em, JpaQueryConfiguration queryConfiguration) { super(method, em); @@ -64,21 +70,19 @@ private NamedQuery(JpaQueryMethod method, EntityManager em) { this.countQueryName = method.getNamedCountQueryName(); QueryExtractor extractor = method.getQueryExtractor(); this.countProjection = method.getCountQueryProjection(); + this.queryRewriter = queryConfiguration.getQueryRewriter(method); Parameters parameters = method.getParameters(); if (parameters.hasSortParameter()) { - throw new IllegalStateException(String.format("Finder method %s is backed by a NamedQuery and must " - + "not contain a sort parameter as we cannot modify the query; Use @Query instead", method)); + throw QueryCreationException.create(method, String.format("Query method is backed by a NamedQuery and must " + + "not contain a sort parameter as we cannot modify the query; Use @%s(value=…) instead to apply sorting or remove the 'Sort' parameter.", + method.isNativeQuery() ? "NativeQuery" : "Query")); } this.namedCountQueryIsPresent = hasNamedQuery(em, countQueryName); - Query query = em.createNamedQuery(queryName); - String queryString = extractor.extractQueryString(query); - - this.declaredQuery = DeclaredQuery.of(queryString, false); - + Query namedQuery = em.createNamedQuery(queryName); boolean weNeedToCreateCountQuery = !namedCountQueryIsPresent && method.getParameters().hasLimitingParameters(); boolean cantExtractQuery = !extractor.canExtractQuery(); @@ -86,13 +90,40 @@ private NamedQuery(JpaQueryMethod method, EntityManager em) { throw QueryCreationException.create(method, CANNOT_EXTRACT_QUERY); } + boolean nativeQuery = method.isNativeQuery() || extractor.isNativeQuery(namedQuery); + String queryString = extractor.extractQueryString(namedQuery); + if (parameters.hasPageableParameter()) { + LOG.warn(String.format( - "Finder method %s is backed by a NamedQuery but contains a Pageable parameter; Sorting delivered via this Pageable will not be applied", - method)); + "Query method %s is backed by a NamedQuery but contains a Pageable parameter; Sorting delivered via this Pageable will not be applied; Use @%s(value=…) instead to apply sorting.", + method, nativeQuery ? "NativeQuery" : "Query")); + } + + // || namedQuery.toString().contains("NativeQuery") + DeclaredQuery declaredQuery; + if (StringUtils.hasText(queryString)) { + if (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.metadataCache = new QueryParameterSetter.QueryMetadataCache(); + this.entityQuery = Lazy.of(() -> EntityQuery.create(declaredQuery, queryConfiguration.getSelector())); } /** @@ -125,14 +156,16 @@ static boolean hasNamedQuery(EntityManager em, String queryName) { * * @param method must not be {@literal null}. * @param em 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) { + public static @Nullable RepositoryQuery lookupFrom(JpaQueryMethod method, EntityManager em, + JpaQueryConfiguration queryConfiguration) { - final String queryName = method.getNamedQueryName(); + String queryName = method.getNamedQueryName(); if (LOG.isDebugEnabled()) { - LOG.debug(String.format("Looking up named query %s", queryName)); + LOG.debug(String.format("Looking up named query '%s'", queryName)); } if (!hasNamedQuery(em, queryName)) { @@ -140,19 +173,21 @@ public static RepositoryQuery lookupFrom(JpaQueryMethod method, EntityManager em } if (method.isScrollQuery()) { - throw QueryCreationException.create(method, "Scroll queries are not supported using String-based queries"); + throw QueryCreationException.create(method, String.format( + "Scroll queries are not supported using String-based queries as we cannot rewrite the query string. Use @%s(value=…) instead.", + method.isNativeQuery() ? "NativeQuery" : "Query")); } - try { - - RepositoryQuery query = new NamedQuery(method, em); - if (LOG.isDebugEnabled()) { - LOG.debug(String.format("Found named query %s", queryName)); - } - return query; - } catch (IllegalArgumentException e) { - return null; + 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 @@ -169,9 +204,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 @@ -180,25 +213,20 @@ protected TypedQuery doCreateCountQuery(JpaParametersParameterAccessor acc EntityManager em = getEntityManager(); TypedQuery countQuery; - String cacheKey; if (namedCountQueryIsPresent) { - cacheKey = countQueryName; countQuery = em.createNamedQuery(countQueryName, Long.class); - } else { - String countQueryString = declaredQuery.deriveCountQuery(countProjection).getQueryString(); - cacheKey = countQueryString; + String countQueryString = entityQuery.get().deriveCountQuery(countProjection).getQueryString(); + countQueryString = potentiallyRewriteQuery(countQueryString, accessor.getSort(), accessor.getPageable()); 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()) { @@ -219,8 +247,24 @@ protected Class getTypeToRead(ReturnedType returnedType) { return type.isInterface() ? Tuple.class : null; } - return declaredQuery.hasConstructorExpression() // + return entityQuery.get().hasConstructorExpression() // ? null // : super.getTypeToRead(returnedType); } + + /** + * Use the {@link QueryRewriter}, potentially rewrite the query, using relevant {@link Sort} and {@link Pageable} + * information. + * + * @param originalQuery + * @param sort + * @param pageable + * @return + */ + private String potentiallyRewriteQuery(String originalQuery, Sort sort, @Nullable Pageable pageable) { + + 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 52dfd37e22..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -19,15 +19,16 @@ 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.QueryRewriter; -import org.springframework.data.repository.query.Parameters; -import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.jpa.repository.NativeQuery; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ReturnedType; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; /** * {@link RepositoryQuery} implementation that inspects a {@link org.springframework.data.repository.query.QueryMethod} @@ -40,7 +41,11 @@ * @author Mark Paluch * @author Greg Turnquist */ -final class NativeJpaQuery extends AbstractStringBasedJpaQuery { +class NativeJpaQuery extends AbstractStringBasedJpaQuery { + + private final @Nullable String sqlResultSetMapping; + + private final boolean queryForEntity; /** * Creates a new {@link NativeJpaQuery} encapsulating the query annotated on the given {@link JpaQueryMethod}. @@ -49,42 +54,73 @@ 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 queryConfiguration must not be {@literal null}. */ - public NativeJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, @Nullable String countQueryString, - QueryRewriter rewriter, QueryMethodEvaluationContextProvider evaluationContextProvider, - SpelExpressionParser parser) { + NativeJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, @Nullable String countQueryString, + JpaQueryConfiguration queryConfiguration) { - super(method, em, queryString, countQueryString, rewriter, evaluationContextProvider, parser); + super(method, em, queryString, countQueryString, queryConfiguration); - Parameters parameters = method.getParameters(); + MergedAnnotations annotations = MergedAnnotations.from(method.getMethod()); + MergedAnnotation annotation = annotations.get(NativeQuery.class); - if (parameters.hasSortParameter() && !queryString.contains("#sort")) { - throw new InvalidJpaQueryMethodException("Cannot use native queries with dynamic sorting in method " + method); - } + 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 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(); - Class type = getTypeToQueryFor(returnedType); + String query = potentiallyRewriteQuery(declaredQuery.getQueryString(), sort, pageable); + + if (!ObjectUtils.isEmpty(sqlResultSetMapping)) { + return em.createNativeQuery(query, sqlResultSetMapping); + } - return type == null ? em.createNativeQuery(potentiallyRewriteQuery(queryString, sort, pageable)) - : em.createNativeQuery(potentiallyRewriteQuery(queryString, sort, pageable), type); + Class type = getTypeToQueryFor(returnedType); + return type == null ? em.createNativeQuery(query) : em.createNativeQuery(query, type); } - @Nullable - private Class getTypeToQueryFor(ReturnedType returnedType) { + private @Nullable Class getTypeToQueryFor(ReturnedType returnedType) { - Class result = getQueryMethod().isQueryForEntity() ? returnedType.getDomainType() : null; + Class result = queryForEntity ? returnedType.getDomainType() : null; - if (this.getQuery().hasConstructorExpression() || this.getQuery().isDefaultProjection()) { + if (getQuery().hasConstructorExpression() || getQuery().isDefaultProjection()) { return result; } - return returnedType.isProjecting() && !getMetamodel().isJpaManaged(returnedType.getReturnedType()) // - ? Tuple.class - : 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/query/ParameterBinder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java index 25f6cff812..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -17,9 +17,11 @@ import jakarta.persistence.Query; +import org.springframework.data.domain.Pageable; 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 @@ -30,8 +32,9 @@ * @author Mark Paluch * @author Christoph Strobl * @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"; @@ -70,18 +73,18 @@ public ParameterBinder(JpaParameters parameters, Iterable this.useJpaForPaging = useJpaForPaging; } - public T bind(T jpaQuery, QueryParameterSetter.QueryMetadata metadata, + public 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); } } @@ -89,20 +92,26 @@ 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); - if (!useJpaForPaging || !parameters.hasLimitingParameters() || accessor.getPageable().isUnpaged()) { + Pageable pageable = accessor.getPageable(); + + if (!useJpaForPaging || !parameters.hasLimitingParameters() || pageable.isUnpaged()) { return query; } - query.setFirstResult(PageableUtils.getOffsetAsInteger(accessor.getPageable())); - query.setMaxResults(accessor.getPageable().getPageSize()); + // Apply offset only if it is not 0 (the default). + int offset = PageableUtils.getOffsetAsInteger(pageable); + if (offset != 0) { + query.setFirstResult(offset); + } + + query.setMaxResults(pageable.getPageSize()); return query; } 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 898cd73936..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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -18,12 +18,11 @@ import java.util.ArrayList; import java.util.List; +import org.springframework.data.expression.ValueEvaluationContextProvider; +import org.springframework.data.expression.ValueExpressionParser; 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.data.repository.query.QueryMethodEvaluationContextProvider; -import org.springframework.expression.spel.standard.SpelExpressionParser; 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 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> metadata) { + static ParameterBinder createBinder(JpaParameters parameters, List 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 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,33 +78,36 @@ static ParameterBinder createCriteriaBinder(JpaParameters parameters, List bindings = query.getParameterBindings(); QueryParameterSetterFactory expressionSetterFactory = QueryParameterSetterFactory.parsing(parser, - evaluationContextProvider, parameters); + evaluationContextProvider); + + QueryParameterSetterFactory basicSetterFactory = QueryParameterSetterFactory.basic(parameters, + query.hasNamedParameter()); - QueryParameterSetterFactory basicSetterFactory = QueryParameterSetterFactory.basic(parameters); + 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 getBindings(JpaParameters parameters) { + static List getBindings(JpaParameters parameters) { - List result = new ArrayList<>(); + List result = new ArrayList<>(parameters.getNumberOfParameters()); int bindableParameterIndex = 0; for (JpaParameter parameter : parameters) { @@ -124,26 +126,26 @@ private static List getBindings(JpaParameters parameters) { private static Iterable createSetters(List parameterBindings, QueryParameterSetterFactory... factories) { - return createSetters(parameterBindings, EmptyDeclaredQuery.EMPTY_QUERY, factories); + return createSetters(parameterBindings, EmptyIntrospectedQuery.INSTANCE, factories); } private static Iterable createSetters(List parameterBindings, - DeclaredQuery declaredQuery, QueryParameterSetterFactory... strategies) { + ParametrizedQuery query, QueryParameterSetterFactory... strategies) { List 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 f60be14198..2122e250e5 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -21,12 +21,24 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; +import java.util.Collections; import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; + +import org.springframework.data.domain.Score; +import org.springframework.data.domain.Vector; +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; @@ -35,8 +47,9 @@ * * @author Thomas Darimont * @author Mark Paluch + * @author Christoph Strobl */ -class ParameterBinding { +public class ParameterBinding { private final BindingIdentifier identifier; private final ParameterOrigin origin; @@ -67,11 +80,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. @@ -142,8 +162,16 @@ public String toString() { /** * @param valueToBind value to prepare */ - @Nullable - public Object prepare(@Nullable Object valueToBind) { + public @Nullable Object prepare(@Nullable Object valueToBind) { + + if (valueToBind instanceof Score score) { + return score.getValue(); + } + + if (valueToBind instanceof Vector v) { + return v.getType() == Float.TYPE ? v.toFloatArray() : v.toDoubleArray(); + } + return valueToBind; } @@ -155,6 +183,10 @@ public Object prepare(@Nullable Object valueToBind) { */ public boolean bindsTo(ParameterBinding other) { + if (getIdentifier().equals(other.getIdentifier())) { + return true; + } + if (identifier.hasName() && other.identifier.hasName()) { if (identifier.getName().equals(other.identifier.getName())) { return true; @@ -181,6 +213,126 @@ 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 + */ + public 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; + private final @Nullable Object value; + + 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.value = value; + 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); + } + + public boolean isIgnoreCase() { + return ignoreCase; + } + + public JpqlQueryTemplates getTemplates() { + return templates; + } + + public @Nullable Object getValue() { + return value; + } + + @Override + public @Nullable Object prepare(@Nullable Object value) { + + value = super.prepare(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) 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 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. @@ -197,7 +349,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; @@ -259,11 +411,10 @@ 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); + Object unwrapped = PersistenceProvider.unwrapTypedParameterValue(super.prepare(value)); if (unwrapped == null) { return null; } @@ -279,12 +430,10 @@ public Object prepare(@Nullable Object value) { @Override public boolean equals(Object obj) { - if (!(obj instanceof LikeParameterBinding)) { + if (!(obj instanceof LikeParameterBinding that)) { return false; } - LikeParameterBinding that = (LikeParameterBinding) obj; - return super.equals(obj) && this.type.equals(that.type); } @@ -323,12 +472,8 @@ static Type getLikeTypeFrom(String expression) { Assert.hasText(expression, "Expression must not be null or empty"); - if (expression.matches("%.*%")) { - return Type.CONTAINING; - } - if (expression.startsWith("%")) { - return Type.ENDING_WITH; + return expression.endsWith("%") ? Type.CONTAINING : Type.ENDING_WITH; } if (expression.endsWith("%")) { @@ -346,7 +491,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}. @@ -420,6 +565,26 @@ default String getName() { default int getPosition() { throw new IllegalStateException("No position associated"); } + + /** + * Map the name of the binding to a new name using the given {@link Function} if the binding has a name. If the + * binding is not associated with a name, then the binding is returned unchanged. + * + * @param nameMapper must not be {@literal null}. + * @return the transformed {@link BindingIdentifier} if the binding has a name, otherwise the binding itself. + * @since 4.0 + */ + BindingIdentifier mapName(Function nameMapper); + + /** + * Associate a position with the binding. + * + * @param position + * @return the new binding identifier with the position. + * @since 4.0 + */ + BindingIdentifier withPosition(int position); + } private record Named(String name) implements BindingIdentifier { @@ -438,6 +603,16 @@ public String getName() { public String toString() { return name(); } + + @Override + public BindingIdentifier mapName(Function nameMapper) { + return new Named(nameMapper.apply(name())); + } + + @Override + public BindingIdentifier withPosition(int position) { + return new NamedAndIndexed(name, position); + } } private record Indexed(int position) implements BindingIdentifier { @@ -452,6 +627,16 @@ public int getPosition() { return position(); } + @Override + public BindingIdentifier mapName(Function nameMapper) { + return this; + } + + @Override + public BindingIdentifier withPosition(int position) { + return new Indexed(position); + } + @Override public String toString() { return "[" + position() + "]"; @@ -480,6 +665,16 @@ public int getPosition() { return position(); } + @Override + public BindingIdentifier mapName(Function nameMapper) { + return new NamedAndIndexed(nameMapper.apply(name), position); + } + + @Override + public BindingIdentifier withPosition(int position) { + return new NamedAndIndexed(name, position); + } + @Override public String toString() { return "[" + name() + ", " + position() + "]"; @@ -492,18 +687,39 @@ 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} string. + * Creates a {@link Expression} for the given {@code expression}. * * @param expression must not be {@literal null}. - * @return {@link Expression} for the given {@code expression} string. + * @return {@link Expression} for the given {@code expression}. */ - static Expression ofExpression(String expression) { + 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} + * + * @param name the parameter name from the method invocation. + * @return {@link MethodInvocationArgument} object for {@code name}. + */ + static MethodInvocationArgument ofParameter(String name) { + return ofParameter(name, null); + } + /** * Creates a {@link MethodInvocationArgument} object for {@code name} and {@code position}. Either the name or the * position must be given. @@ -519,13 +735,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}. * @@ -555,6 +783,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(); } /** @@ -564,7 +797,7 @@ static MethodInvocationArgument ofParameter(BindingIdentifier identifier) { * @author Mark Paluch * @since 3.1.2 */ - public record Expression(String expression) implements ParameterOrigin { + public record Expression(ValueExpression expression) implements ParameterOrigin { @Override public boolean isMethodArgument() { @@ -575,6 +808,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; + } } /** @@ -595,5 +858,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 213e641a5c..968751704e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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,34 +15,34 @@ */ 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.stream.Collectors; +import java.util.Set; + +import org.jspecify.annotations.Nullable; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Score; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.Vector; 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; import org.springframework.data.repository.query.parser.Part; -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; -import org.springframework.util.ObjectUtils; /** - * Helper class to allow easy creation of {@link ParameterMetadata}s. + * Helper class to allow easy creation of {@link PartTreeParameterBinding}s. * * @author Oliver Gierke * @author Thomas Darimont @@ -54,109 +54,134 @@ * @author Donghun Shin * @author Greg Turnquist */ -class ParameterMetadataProvider { +public class ParameterMetadataProvider { + + static final Object PLACEHOLDER = new Object(); - private final CriteriaBuilder builder; private final Iterator parameters; - private final List> expressions; + private final @Nullable JpaParametersParameterAccessor accessor; + private final List bindings; + private final Set syntheticParameterNames = new LinkedHashSet<>(); + private @Nullable ParameterBinding vector; private final @Nullable Iterator bindableParameterValues; private final EscapeCharacter escape; + private final JpqlQueryTemplates templates; + private final JpaParameters jpaParameters; + private int position; + private int bindMarker; /** * 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, 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, 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 bindableParameterValues, - Parameters parameters, EscapeCharacter escape) { - - Assert.notNull(builder, "CriteriaBuilder must not be null"); + private ParameterMetadataProvider(@Nullable Iterator bindableParameterValues, + @Nullable JpaParametersParameterAccessor accessor, JpaParameters parameters, EscapeCharacter escape, + JpqlQueryTemplates templates) { 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.accessor = accessor; this.parameters = parameters.getBindableParameters().iterator(); - this.expressions = new ArrayList<>(); + this.bindings = new ArrayList<>(); this.bindableParameterValues = bindableParameterValues; this.escape = escape; + this.templates = templates; + } + + JpaParameters getParameters() { + return this.jpaParameters; } /** - * Returns all {@link ParameterMetadata}s built. + * Returns all {@link ParameterBinding}s built. * - * @return the expressions + * @return the bindings. */ - public List> getExpressions() { - return expressions; + public List getBindings() { + return bindings; } /** - * Builds a new {@link ParameterMetadata} for given {@link Part} and the next {@link Parameter}. + * @return the {@link SimilarityNormalizer}. + */ + SimilarityNormalizer getSimilarityNormalizer() { + + if (accessor != null && accessor.normalizeSimilarity()) { + return SimilarityNormalizer.get(accessor.getScoringFunction()); + } + + return SimilarityNormalizer.IDENTITY; + } + + /** + * Builds a new {@link PartTreeParameterBinding} for given {@link Part} and the next {@link Parameter}. */ @SuppressWarnings("unchecked") - public ParameterMetadata next(Part part) { + PartTreeParameterBinding next(Part part) { Assert.isTrue(parameters.hasNext(), () -> String.format("No parameter available for part %s", part)); Parameter parameter = parameters.next(); - return (ParameterMetadata) 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 is the type parameter of the returned {@link ParameterMetadata}. + * @param is the type parameter of the returned {@link PartTreeParameterBinding}. * @param type must not be {@literal null}. * @return ParameterMetadata for the next parameter. */ - @SuppressWarnings("unchecked") - public ParameterMetadata next(Part part, Class type) { + PartTreeParameterBinding next(Part part, Class type) { Parameter parameter = parameters.next(); Class typeToUse = ClassUtils.isAssignable(type, parameter.getType()) ? parameter.getType() : type; - return (ParameterMetadata) 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 type parameter for the returned {@link ParameterMetadata}. + * @param type parameter for the returned {@link PartTreeParameterBinding}. * @param part must not be {@literal null}. * @param type must not be {@literal null}. - * @param parameter providing the name for the returned {@link ParameterMetadata}. - * @return a new {@link ParameterMetadata} for the given type and name. + * @param parameter providing the name for the returned {@link PartTreeParameterBinding}. + * @return a new {@link PartTreeParameterBinding} for the given type and name. */ - private ParameterMetadata next(Part part, Class type, Parameter parameter) { + private PartTreeParameterBinding next(Part part, Class type, Parameter parameter) { Assert.notNull(type, "Type must not be null"); @@ -166,19 +191,71 @@ private ParameterMetadata next(Part part, Class type, Parameter parame @SuppressWarnings("unchecked") Class reifiedType = Expression.class.equals(type) ? (Class) Object.class : type; - Supplier name = () -> parameter.getName() - .orElseThrow(() -> new IllegalArgumentException("o_O Parameter needs to be named")); + Object value = bindableParameterValues == null ? PLACEHOLDER : bindableParameterValues.next(); + int currentPosition = ++position; + int currentBindMarker = ++bindMarker; + + BindingIdentifier bindingIdentifier = parameter.getName().map(it -> BindingIdentifier.of(it, currentBindMarker)) + .orElseGet(() -> BindingIdentifier.of(currentBindMarker)); + + BindingIdentifier origin = parameter.getName().map(it -> BindingIdentifier.of(it, currentPosition)) + .orElseGet(() -> BindingIdentifier.of(currentPosition)); - ParameterExpression expression = parameter.isExplicitlyNamed() // - ? builder.parameter(reifiedType, name.get()) // - : builder.parameter(reifiedType); + /* identifier refers to bindable parameters, not _all_ parameters index */ + MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(origin); + PartTreeParameterBinding binding = new PartTreeParameterBinding(bindingIdentifier, + methodParameter, reifiedType, part, value, templates, escape); - Object value = bindableParameterValues == null ? ParameterMetadata.PLACEHOLDER : bindableParameterValues.next(); + // PartTreeParameterBinding is more expressive than a potential ParameterBinding for Vector. + bindings.add(binding); - ParameterMetadata metadata = new ParameterMetadata<>(expression, part, value, escape); - expressions.add(metadata); + if (Vector.class.isAssignableFrom(parameter.getType())) { + this.vector = binding; + } - return metadata; + return binding; + } + + /** + * @return the scoring function if available {@link ScoringFunction#unspecified()} by default. + * @since 4.0 + */ + ScoringFunction getScoringFunction() { + + if (accessor != null) { + return accessor.getScoringFunction(); + } + + return ScoringFunction.unspecified(); + } + + /** + * + * @return the vector binding identifier. + * @throws IllegalStateException if parameters do not cotain + * @since 4.0 + */ + ParameterBinding getVectorBinding() { + + if (!getParameters().hasVectorParameter()) { + throw new IllegalStateException("Vector parameter not available"); + } + + if (this.vector != null) { + return this.vector; + } + + int vectorIndex = getParameters().getVectorIndex(); + + BindingIdentifier bindingIdentifier = BindingIdentifier.of(vectorIndex + 1); + + /* identifier refers to bindable parameters, not _all_ parameters index */ + MethodInvocationArgument methodParameter = ParameterOrigin.ofParameter(bindingIdentifier); + ParameterBinding parameterBinding = new ParameterBinding(bindingIdentifier, methodParameter); + + this.bindings.add(parameterBinding); + + return parameterBinding; } EscapeCharacter getEscape() { @@ -186,123 +263,146 @@ EscapeCharacter getEscape() { } /** - * @author Oliver Gierke - * @author Thomas Darimont - * @author Andrey Kovalev - * @param + * 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. */ - static class ParameterMetadata { + ParameterBinding nextSynthetic(String nameHint, Object value, Object source) { - static final Object PLACEHOLDER = new Object(); + int currentPosition = ++bindMarker; + String bindingName = nameHint; - private final Type type; - private final ParameterExpression expression; - private final EscapeCharacter escape; - private final boolean ignoreCase; - private final boolean noWildcards; + if (!syntheticParameterNames.add(bindingName)) { - /** - * Creates a new {@link ParameterMetadata}. - */ - public ParameterMetadata(ParameterExpression expression, Part part, @Nullable Object value, - EscapeCharacter escape) { - - this.expression = expression; - this.type = value == null && Type.SIMPLE_PROPERTY.equals(part.getType()) ? Type.IS_NULL : part.getType(); - this.ignoreCase = IgnoreCaseType.ALWAYS.equals(part.shouldIgnoreCase()); - this.noWildcards = part.getProperty().getLeafProperty().isCollection(); - this.escape = escape; + bindingName = bindingName + "_" + currentPosition; + syntheticParameterNames.add(bindingName); } - /** - * Returns the {@link ParameterExpression}. - * - * @return the expression - */ - public ParameterExpression getExpression() { - return expression; + return new ParameterBinding(BindingIdentifier.of(bindingName, currentPosition), + ParameterOrigin.synthetic(value, source)); + } + + RangeParameterBinding lower(PartTreeParameterBinding within, SimilarityNormalizer normalizer) { + + int bindMarker = within.getRequiredPosition(); + + if (!bindings.remove(within)) { + bindMarker = ++this.bindMarker; } - /** - * Returns whether the parameter shall be considered an {@literal IS NULL} parameter. - */ - public boolean isIsNullParameter() { - return Type.IS_NULL.equals(type); + BindingIdentifier identifier = within.getIdentifier(); + RangeParameterBinding rangeBinding = new RangeParameterBinding( + identifier.mapName(name -> name + "_lower").withPosition(bindMarker), within.getOrigin(), true, normalizer); + bindings.add(rangeBinding); + + return rangeBinding; + } + + RangeParameterBinding upper(PartTreeParameterBinding within, SimilarityNormalizer normalizer) { + + int bindMarker = within.getRequiredPosition(); + + if (!bindings.remove(within)) { + bindMarker = ++this.bindMarker; } + BindingIdentifier identifier = within.getIdentifier(); + RangeParameterBinding rangeBinding = new RangeParameterBinding( + identifier.mapName(name -> name + "_upper").withPosition(bindMarker), within.getOrigin(), false, normalizer); + bindings.add(rangeBinding); + + return rangeBinding; + } + + ScoreParameterBinding normalize(PartTreeParameterBinding within, SimilarityNormalizer normalizer) { + + bindings.remove(within); + + ScoreParameterBinding rangeBinding = new ScoreParameterBinding(within.getIdentifier(), within.getOrigin(), + normalizer); + bindings.add(rangeBinding); + + return rangeBinding; + } + + static class ScoreParameterBinding extends ParameterBinding { + + private final SimilarityNormalizer normalizer; + /** - * Prepares the object before it's actually bound to the {@link jakarta.persistence.Query;}. + * Creates a new {@link ParameterBinding} for the parameter with the given identifier and origin. * - * @param value can be {@literal null}. + * @param identifier of the parameter, must not be {@literal null}. + * @param origin the origin of the parameter (expression or method argument) */ - @Nullable - public Object prepare(@Nullable Object value) { + ScoreParameterBinding(BindingIdentifier identifier, ParameterOrigin origin, SimilarityNormalizer normalizer) { + super(identifier, origin); + this.normalizer = normalizer; + } + + @Override + public @Nullable Object prepare(@Nullable Object valueToBind) { - if (value == null || expression.getJavaType() == null) { - return value; + if (valueToBind instanceof Score score) { + return normalizer.getScore(score.getValue()); } - 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; - } + return super.prepare(valueToBind); + } + + @Override + public boolean isCompatibleWith(ParameterBinding binding) { + + if (super.isCompatibleWith(binding) && binding instanceof ScoreParameterBinding other) { + return normalizer == other.normalizer; } - return Collection.class.isAssignableFrom(expression.getJavaType()) // - ? upperIfIgnoreCase(ignoreCase, toCollection(value)) // - : value; + return false; } + } + + static class RangeParameterBinding extends ScoreParameterBinding { + + private final boolean lower; /** - * 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}. + * Creates a new {@link ParameterBinding} for the parameter with the given identifier and origin. * - * @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. + * @param identifier of the parameter, must not be {@literal null}. + * @param origin the origin of the parameter (expression or method argument) */ - @Nullable - private static Collection toCollection(@Nullable Object value) { - - if (value == null) { - return null; - } - - if (value instanceof Collection collection) { - return collection.isEmpty() ? null : collection; - } + RangeParameterBinding(BindingIdentifier identifier, ParameterOrigin origin, boolean lower, + SimilarityNormalizer normalizer) { + super(identifier, origin, normalizer); + this.lower = lower; + } - if (ObjectUtils.isArray(value)) { + @Override + public @Nullable Object prepare(@Nullable Object valueToBind) { - List collection = Arrays.asList(ObjectUtils.toObjectArray(value)); - return collection.isEmpty() ? null : collection; + if (valueToBind instanceof Range r) { + if (lower) { + return super.prepare(r.getLowerBound().getValue().orElse(null)); + } else { + return super.prepare(r.getUpperBound().getValue().orElse(null)); + } } - return Collections.singleton(value); + return super.prepare(valueToBind); } - @Nullable - @SuppressWarnings("unchecked") - private static Collection upperIfIgnoreCase(boolean ignoreCase, @Nullable Collection collection) { + @Override + public boolean isCompatibleWith(ParameterBinding binding) { - if (!ignoreCase || CollectionUtils.isEmpty(collection)) { - return collection; + if (super.isCompatibleWith(binding) && binding instanceof RangeParameterBinding other) { + return lower == other.lower; } - return ((Collection) collection).stream() // - .map(it -> it == null // - ? null // - : it.toUpperCase()) // - .collect(Collectors.toList()); + return false; } } + } 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. + *

+ * 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 getParameterBindings(); + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParsedQueryIntrospector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParsedQueryIntrospector.java new file mode 100644 index 0000000000..caf74035ba --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParsedQueryIntrospector.java @@ -0,0 +1,44 @@ +/* + * 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 org.antlr.v4.runtime.tree.ParseTree; + +/** + * Interface defining an introspector for String-queries providing details about the primary table alias, the primary + * selection projection and whether the query makes use of constructor expressions. + * + * @author Mark Paluch + * @since 3.5 + */ +interface ParsedQueryIntrospector { + + /** + * Visit the parsed tree to introspect the AST tree. + * + * @param tree + * @return + */ + Void visit(ParseTree tree); + + /** + * Returns the parsed query information. The information is available after the {@link #visit(ParseTree) + * introspection} has been completed. + * + * @return parsed query information. + */ + I getParsedQueryInformation(); +} 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 3b799258dc..9d5c42c572 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -16,14 +16,17 @@ package org.springframework.data.jpa.repository.query; 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 org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.ScrollPosition; @@ -32,15 +35,18 @@ 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.JpaEntityInformation; +import org.springframework.data.jpa.repository.support.JpaEntityInformationSupport; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; +import org.springframework.data.repository.query.QueryCreationException; 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.Lazy; 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}. @@ -54,14 +60,17 @@ */ 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; - private final JpaMetamodelEntityInformation entityInformation; + private final Lazy> entityInformation; /** * Creates a new {@link PartTreeJpaQuery}. @@ -89,28 +98,28 @@ public class PartTreeJpaQuery extends AbstractJpaQuery { this.parameters = method.getParameters(); Class domainClass = method.getEntityInformation().getJavaType(); - PersistenceUnitUtil persistenceUnitUtil = em.getEntityManagerFactory().getPersistenceUnitUtil(); - this.entityInformation = new JpaMetamodelEntityInformation<>(domainClass, em.getMetamodel(), persistenceUnitUtil); - - boolean recreationRequired = parameters.hasDynamicProjection() || parameters.potentiallySortsDynamically() - || method.isScrollQuery(); + this.entityInformation = Lazy.of(() -> JpaEntityInformationSupport.getEntityInformation(domainClass, em)); 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); + validate(tree, parameters); + this.countQuery = new CountQueryPreparer(); + this.queryPreparer = tree.isCountProjection() ? countQuery : new QueryPreparer(); } catch (Exception o_O) { - throw new IllegalArgumentException( - String.format("Failed to create query for method %s; %s", method, o_O.getMessage()), o_O); + throw QueryCreationException.create(getQueryMethod(), o_O); } } + @Override + public boolean hasDeclaredCountQuery() { + return false; + } + @Override public Query doCreateQuery(JpaParametersParameterAccessor accessor) { - return query.createQuery(accessor); + return queryPreparer.createQuery(accessor); } @Override @@ -120,20 +129,20 @@ public TypedQuery doCreateCountQuery(JpaParametersParameterAccessor access } @Override - protected JpaQueryExecution getExecution() { + protected JpaQueryExecution getExecution(JpaParametersParameterAccessor accessor) { if (this.getQueryMethod().isScrollQuery()) { - return new ScrollExecution(this.tree.getSort(), new ScrollDelegate<>(entityInformation)); + return new ScrollExecution(this.tree.getSort(), new ScrollDelegate<>(entityInformation.get())); } else if (this.tree.isDelete()) { return new DeleteExecution(em); } else if (this.tree.isExistsProjection()) { return new ExistsExecution(); } - return super.getExecution(); + return super.getExecution(accessor); } - private static void validate(PartTree tree, JpaParameters parameters, String methodName) { + private static void validate(PartTree tree, JpaParameters parameters) { int argCount = 0; @@ -145,14 +154,14 @@ private static void validate(PartTree tree, JpaParameters parameters, String met for (int i = 0; i < numberOfArguments; i++) { - throwExceptionOnArgumentMismatch(methodName, part, parameters, argCount); + throwExceptionOnArgumentMismatch(part, parameters, argCount); argCount++; } } } - private static void throwExceptionOnArgumentMismatch(String methodName, Part part, JpaParameters parameters, + private static void throwExceptionOnArgumentMismatch(Part part, JpaParameters parameters, int index) { Type type = part.getType(); @@ -160,28 +169,28 @@ private static void throwExceptionOnArgumentMismatch(String methodName, Part par if (!parameters.getBindableParameters().hasParameterAt(index)) { throw new IllegalStateException(String.format( - "Method %s expects at least %d arguments but only found %d; This leaves an operator of type %s for property %s unbound", - methodName, index + 1, index, type.name(), property)); + "Method expects at least %d arguments but only found %d; This leaves an operator of type '%s' for property '%s' unbound", + index + 1, index, type.name(), property)); } JpaParameter parameter = parameters.getBindableParameter(index); if (expectsCollection(type)) { if (!parameterIsCollectionLike(parameter)) { - throw new IllegalStateException(wrongParameterTypeMessage(methodName, property, type, "Collection", parameter)); + throw new IllegalStateException(wrongParameterTypeMessage(property, type, "Collection", parameter)); } } else { if (!part.getProperty().isCollection() && !parameterIsScalarLike(parameter)) { - throw new IllegalStateException(wrongParameterTypeMessage(methodName, property, type, "scalar", parameter)); + throw new IllegalStateException(wrongParameterTypeMessage(property, type, "scalar", parameter)); } } } - private static String wrongParameterTypeMessage(String methodName, String property, Type operatorType, + private static String wrongParameterTypeMessage(String property, Type operatorType, String expectedArgumentType, JpaParameter parameter) { - return String.format("Operator %s on %s requires a %s argument, found %s in method %s", operatorType.name(), - property, expectedArgumentType, parameter.getType(), methodName); + return String.format("Operator '%s' on '%s' requires a %s argument, found '%s'", operatorType.name(), property, + expectedArgumentType, parameter.getType()); } private static boolean parameterIsCollectionLike(JpaParameter parameter) { @@ -207,56 +216,42 @@ private static boolean expectsCollection(Type type) { */ private class QueryPreparer { - private final @Nullable CriteriaQuery cachedCriteriaQuery; - 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> 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()) { @@ -287,62 +282,86 @@ 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) { + protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) { - if (this.cachedCriteriaQuery != null) { - synchronized (this.cachedCriteriaQuery) { - return getEntityManager().createQuery(criteriaQuery); - } + JpqlQueryCreator jpqlQueryCreator = cache.get(sort, accessor); // this caching thingy is broken due to IS NULL + // rendering for + if (jpqlQueryCreator != null) { + return jpqlQueryCreator; + } + + 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.get(), keyset, + entityManager); + } + + JpaParameters parameters = getQueryMethod().getParameters(); + if (accessor.getParameters().hasDynamicProjection() || getQueryMethod().isSearchQuery() + || parameters.hasScoreRangeParameter() || parameters.hasScoreParameter()) { + return new JpaQueryCreator(tree, getQueryMethod().isSearchQuery(), returnedType, provider, templates, + entityInformation.get(), em.getMetamodel()); } - return getEntityManager().createQuery(criteriaQuery); + JpqlQueryCreator creator = new CacheableJpqlQueryCreator(sort, new JpaQueryCreator(tree, + getQueryMethod().isSearchQuery(), returnedType, provider, templates, entityInformation.get(), + em.getMetamodel())); + + 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 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 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> 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) { @@ -361,37 +380,72 @@ 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, entityInformation.get(), + em.getMetamodel()); - 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 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 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 cache = Collections.synchronizedMap(new LinkedHashMap<>() { + @Override + protected boolean removeEldestEntry(Map.Entry 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/PreprocessedQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java new file mode 100644 index 0000000000..c4dd4a2ac5 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java @@ -0,0 +1,628 @@ +/* + * 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.util.regex.Pattern.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +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.repository.query.ValueExpressionQueryRewriter; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.util.Assert; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * A pre-parsed query implementing {@link DeclaredQuery} providing information about parameter bindings. + *

+ * 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). + *

+ * 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 Christoph Strobl + * @author Mark Paluch + * @since 4.0 + */ +public final class PreprocessedQuery implements DeclaredQuery { + + private final DeclaredQuery source; + private final List bindings; + private final boolean usesJdbcStyleParameters; + private final boolean containsPageableInSpel; + private final boolean hasNamedBindings; + + private PreprocessedQuery(DeclaredQuery query, List bindings, boolean usesJdbcStyleParameters, + boolean containsPageableInSpel) { + this.source = query; + this.bindings = bindings; + this.usesJdbcStyleParameters = usesJdbcStyleParameters; + this.containsPageableInSpel = containsPageableInSpel; + this.hasNamedBindings = containsNamedParameter(bindings); + } + + private static boolean containsNamedParameter(List bindings) { + + for (ParameterBinding parameterBinding : bindings) { + if (parameterBinding.getIdentifier().hasName() && parameterBinding.getOrigin() + .isMethodArgument()) { + return true; + } + } + return false; + } + + /** + * 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}. + */ + public static PreprocessedQuery parse(DeclaredQuery declaredQuery) { + return ParameterBindingParser.INSTANCE.parse(declaredQuery.getQueryString(), declaredQuery::rewrite, + parameterBindings -> { + }); + } + + @Override + public String getQueryString() { + return source.getQueryString(); + } + + @Override + public boolean isNative() { + return source.isNative(); + } + + boolean hasBindings() { + return !bindings.isEmpty(); + } + + boolean hasNamedBindings() { + return this.hasNamedBindings; + } + + public boolean containsPageableInSpel() { + return containsPageableInSpel; + } + + boolean usesJdbcStyleParameters() { + return usesJdbcStyleParameters; + } + + public List getBindings() { + return Collections.unmodifiableList(bindings); + } + + /** + * 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 + */ + @Override + public PreprocessedQuery rewrite(String newQueryString) { + + return ParameterBindingParser.INSTANCE.parse(newQueryString, source::rewrite, 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.hasBindings() && !this.bindings.equals(derivedBindings)) { + + for (ParameterBinding binding : bindings) { + + Predicate identifier = binding::bindsTo; + Predicate notCompatible = Predicate.not(binding::isCompatibleWith); + + // replace incompatible bindings + if (derivedBindings.removeIf(it -> identifier.test(it) && notCompatible.test(it))) { + derivedBindings.add(binding); + } + } + } + }); + } + + @Override + public String toString() { + return "ParametrizedQuery[" + source + ", " + bindings + ']'; + } + + /** + * A parser that extracts the parameter bindings from a given query string. + * + * @author Thomas Darimont + */ + enum ParameterBindingParser { + + INSTANCE; + + private static final String EXPRESSION_PARAMETER_PREFIX = "__$synthetic$__"; + public static final String POSITIONAL_OR_INDEXED_PARAMETER = "\\?(\\d*+(?![\\&\\|#\\w]))"; + // .....................................................................^ not followed by a hash or a letter. + // .................................................................^ zero or more digits. + // .............................................................^ start with a question mark. + private static final Pattern PARAMETER_BINDING_BY_INDEX = Pattern.compile(POSITIONAL_OR_INDEXED_PARAMETER); + private static final Pattern PARAMETER_BINDING_PATTERN; + private static final Pattern JDBC_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?(?!\\d)"); // no \ and [no digit] + private static final Pattern NUMBERED_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?\\d"); // no \ and [digit] + 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"; + private static final int INDEXED_PARAMETER_GROUP = 4; + private static final int NAMED_PARAMETER_GROUP = 6; + private static final int COMPARISION_TYPE_GROUP = 1; + + static { + + List keywords = new ArrayList<>(); + + for (ParameterBindingType type : ParameterBindingType.values()) { + if (type.getKeyword() != null) { + keywords.add(type.getKeyword()); + } + } + + StringBuilder builder = new StringBuilder(); + builder.append("("); + builder.append(StringUtils.collectionToDelimitedString(keywords, "|")); // keywords + builder.append(")?"); + builder.append("(?: )?"); // some whitespace + builder.append("\\(?"); // optional braces around parameters + builder.append("("); + builder.append("%?(" + POSITIONAL_OR_INDEXED_PARAMETER + ")%?"); // position parameter and parameter index + builder.append("|"); // or + + // named parameter and the parameter name + builder.append("%?(" + QueryUtils.COLON_NO_DOUBLE_COLON + QueryUtils.IDENTIFIER_GROUP + ")%?"); + + builder.append(")"); + builder.append("\\)?"); // optional braces around parameters + + PARAMETER_BINDING_PATTERN = Pattern.compile(builder.toString(), CASE_INSENSITIVE); + } + + /** + * Parses {@link ParameterBinding} instances from the given query and adds them to the registered bindings. Returns + * the cleaned up query. + */ + PreprocessedQuery parse(String query, Function declaredQueryFactory, + Consumer> parameterBindingPostProcessor) { + + IndexedParameterLabels parameterLabels = new IndexedParameterLabels(findParameterIndices(query)); + boolean parametersShouldBeAccessedByIndex = parameterLabels.hasLabels(); + + List bindings = new ArrayList<>(); + boolean jdbcStyle = false; + boolean containsPageableInSpel = query.contains("#pageable"); + + /* + * Prefer indexed access over named parameters if only SpEL Expression parameters are present. + */ + if (!parametersShouldBeAccessedByIndex && query.contains("?#{")) { + parametersShouldBeAccessedByIndex = true; + } + + ValueExpressionQueryRewriter.ParsedQuery parsedQuery = createSpelExtractor(query, + parametersShouldBeAccessedByIndex, parameterLabels); + + String resultingQuery = parsedQuery.getQueryString(); + Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(resultingQuery); + + ParameterBindings parameterBindings = new ParameterBindings(bindings, it -> checkAndRegister(it, bindings)); + int currentIndex = 0; + + boolean usesJpaStyleParameters = false; + + while (matcher.find()) { + + if (parsedQuery.isQuoted(matcher.start())) { + continue; + } + + String parameterIndexString = matcher.group(INDEXED_PARAMETER_GROUP); + String parameterName = parameterIndexString != null ? null : matcher.group(NAMED_PARAMETER_GROUP); + Integer parameterIndex = getParameterIndex(parameterIndexString); + + String match = matcher.group(0); + Matcher jdbcStyleMatcher = JDBC_STYLE_PARAM.matcher(match); + + if (jdbcStyleMatcher.find()) { + jdbcStyle = true; + } + + if (NUMBERED_STYLE_PARAM.matcher(match) + .find() || NAMED_STYLE_PARAM.matcher(match).find()) { + usesJpaStyleParameters = true; + } + + if (usesJpaStyleParameters && jdbcStyle) { + throw new IllegalArgumentException("Mixing of ? parameters and other forms like ?1 is not supported"); + } + + String typeSource = matcher.group(COMPARISION_TYPE_GROUP); + Assert.isTrue(parameterIndexString != null || parameterName != null, + () -> String.format("We need either a name or an index; Offending query string: %s", query)); + ValueExpression expression = parsedQuery + .getParameter(parameterName == null ? parameterIndexString : parameterName); + String replacement = null; + + // this only happens for JDBC-style parameters. + if ("".equals(parameterIndexString)) { + parameterIndex = parameterLabels.allocate(); + } + + ParameterBinding.BindingIdentifier queryParameter; + if (parameterIndex != null) { + queryParameter = ParameterBinding.BindingIdentifier.of(parameterIndex); + } + 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); + + ParameterBinding.BindingIdentifier targetBinding = queryParameter; + Function bindingFactory = switch (ParameterBindingType + .of(typeSource)) { + case LIKE -> { + + 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 { + targetBinding = parameterBindings.register(queryParameter, origin, bindingFactory, parameterLabels); + } + + replacement = targetBinding.hasName() ? ":" + targetBinding.getName() + : ((!usesJpaStyleParameters && jdbcStyle) ? "?" : "?" + targetBinding.getPosition()); + String result; + String substring = matcher.group(2); + + int index = resultingQuery.indexOf(substring, currentIndex); + if (index < 0) { + result = resultingQuery; + } + else { + currentIndex = index + replacement.length(); + result = resultingQuery.substring(0, index) + replacement + + resultingQuery.substring(index + substring.length()); + } + + resultingQuery = result; + } + + parameterBindingPostProcessor.accept(bindings); + return new PreprocessedQuery(declaredQueryFactory.apply(resultingQuery), bindings, jdbcStyle, + containsPageableInSpel); + } + + private static ValueExpressionQueryRewriter.ParsedQuery createSpelExtractor(String queryWithSpel, + boolean parametersShouldBeAccessedByIndex, IndexedParameterLabels parameterLabels) { + + /* + * If parameters need to be bound by index, we bind the synthetic expression parameters starting from position of the greatest discovered index parameter in order to + * not mix-up with the actual parameter indices. + */ + BiFunction indexToParameterName = parametersShouldBeAccessedByIndex + ? (index, expression) -> String.valueOf(parameterLabels.allocate()) + : (index, expression) -> EXPRESSION_PARAMETER_PREFIX + (index + 1); + + String fixedPrefix = parametersShouldBeAccessedByIndex ? "?" : ":"; + + BiFunction parameterNameToReplacement = (prefix, name) -> fixedPrefix + name; + ValueExpressionQueryRewriter rewriter = ValueExpressionQueryRewriter.of(ValueExpressionParser.create(), + indexToParameterName, parameterNameToReplacement); + + return rewriter.parse(queryWithSpel); + } + + private static @Nullable Integer getParameterIndex(@Nullable String parameterIndexString) { + + if (parameterIndexString == null || parameterIndexString.isEmpty()) { + return null; + } + return Integer.valueOf(parameterIndexString); + } + + private static Set findParameterIndices(String query) { + + Matcher parameterIndexMatcher = PARAMETER_BINDING_BY_INDEX.matcher(query); + Set usedParameterIndices = new TreeSet<>(); + + while (parameterIndexMatcher.find()) { + + String parameterIndexString = parameterIndexMatcher.group(1); + Integer parameterIndex = getParameterIndex(parameterIndexString); + if (parameterIndex != null) { + usedParameterIndices.add(parameterIndex); + } + } + + return usedParameterIndices; + } + + private static void checkAndRegister(ParameterBinding binding, List bindings) { + + bindings.stream() // + .filter(it -> it.bindsTo(binding)) // + .forEach(it -> Assert.isTrue(it.equals(binding), String.format(MESSAGE, it, binding))); + + if (!bindings.contains(binding)) { + bindings.add(binding); + } + } + + /** + * An enum for the different types of bindings. + * + * @author Thomas Darimont + * @author Oliver Gierke + */ + private enum ParameterBindingType { + + // Trailing whitespace is intentional to reflect that the keywords must be used with at least one whitespace + // character, while = does not. + LIKE("like "), IN("in "), AS_IS(null); + + private final @Nullable String keyword; + + ParameterBindingType(@Nullable String keyword) { + this.keyword = keyword; + } + + /** + * Returns the keyword that will trigger the binding type or {@literal null} if the type is not triggered by a + * keyword. + * + * @return the keyword + */ + public @Nullable String getKeyword() { + return keyword; + } + + /** + * Return the appropriate {@link ParameterBindingType} for the given {@link String}. Returns {@literal #AS_IS} in + * case no other {@link ParameterBindingType} could be found. + */ + static ParameterBindingType of(String typeSource) { + + if (!StringUtils.hasText(typeSource)) { + return AS_IS; + } + + for (ParameterBindingType type : values()) { + if (type.name().equalsIgnoreCase(typeSource.trim())) { + return type; + } + } + + throw new IllegalArgumentException(String.format("Unsupported parameter binding type %s", typeSource)); + } + } + } + + /** + * 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 ParameterBinding.LikeParameterBinding#prepare(Object) LIKE + * rewrite}. + * + * @author Mark Paluch + * @since 3.1.2 + */ + private static class ParameterBindings { + + private final MultiValueMap methodArgumentToLikeBindings = new LinkedMultiValueMap<>(); + + private final Consumer registration; + + public ParameterBindings(List bindings, Consumer registration) { + + for (ParameterBinding binding : bindings) { + this.methodArgumentToLikeBindings.put(binding.getIdentifier(), new ArrayList<>(List.of(binding))); + } + + this.registration = registration; + } + + /** + * @param identifier + * @return whether the identifier is already bound. + */ + public boolean isBound(ParameterBinding.BindingIdentifier identifier) { + return !getBindings(identifier).isEmpty(); + } + + ParameterBinding.BindingIdentifier register(ParameterBinding.BindingIdentifier identifier, + ParameterBinding.ParameterOrigin origin, + Function bindingFactory, + IndexedParameterLabels parameterLabels) { + + Assert.isInstanceOf(ParameterBinding.MethodInvocationArgument.class, origin); + + ParameterBinding.BindingIdentifier methodArgument = ((ParameterBinding.MethodInvocationArgument) origin) + .identifier(); + List bindingsForOrigin = getBindings(methodArgument); + + if (!isBound(identifier)) { + + ParameterBinding binding = bindingFactory.apply(identifier); + registration.accept(binding); + bindingsForOrigin.add(binding); + return binding.getIdentifier(); + } + + ParameterBinding binding = bindingFactory.apply(identifier); + + for (ParameterBinding existing : bindingsForOrigin) { + + if (existing.isCompatibleWith(binding)) { + return existing.getIdentifier(); + } + } + + ParameterBinding.BindingIdentifier syntheticIdentifier; + if (identifier.hasName() && methodArgument.hasName()) { + + int index = 0; + String newName = methodArgument.getName(); + while (existsBoundParameter(newName)) { + index++; + newName = methodArgument.getName() + "_" + index; + } + syntheticIdentifier = ParameterBinding.BindingIdentifier.of(newName); + } + else { + syntheticIdentifier = ParameterBinding.BindingIdentifier.of(parameterLabels.allocate()); + } + + ParameterBinding newBinding = bindingFactory.apply(syntheticIdentifier); + registration.accept(newBinding); + bindingsForOrigin.add(newBinding); + return newBinding.getIdentifier(); + } + + private boolean existsBoundParameter(String key) { + return methodArgumentToLikeBindings.values().stream() + .flatMap(Collection::stream) + .anyMatch(it -> key.equals(it.getName())); + } + + private List getBindings(ParameterBinding.BindingIdentifier identifier) { + return methodArgumentToLikeBindings.computeIfAbsent(identifier, s -> new ArrayList<>()); + } + + 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 usedLabels; + private final boolean sequential; + + public IndexedParameterLabels(Set usedLabels) { + + this.usedLabels = usedLabels instanceof TreeSet ts ? ts : new TreeSet(usedLabels); + this.sequential = isSequential(usedLabels); + } + + private static boolean isSequential(Set 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/Procedure.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Procedure.java index 6e6a825259..9ebbe5f2a8 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Procedure.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Procedure.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. 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 c589001f5e..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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. @@ -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 @@ -77,11 +77,10 @@ public boolean equals(Object o) { return true; } - if (!(o instanceof ProcedureParameter)) { + if (!(o instanceof ProcedureParameter that)) { return false; } - ProcedureParameter that = (ProcedureParameter) o; return Objects.equals(name, that.name) && mode == that.mode && Objects.equals(type, that.type); } 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 ec28f9e5af..510e15f5e1 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -15,86 +15,108 @@ */ 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.lang.Nullable; +import org.springframework.data.repository.query.ReturnedType; /** - * This interface describes the API for enhancing a given Query. + * This interface describes the API for enhancing a given Query. Query enhancers understand the syntax of the query and + * can introspect queries to determine aliases and projections. Enhancers can also rewrite queries to apply sorting and + * create count queries if the underlying query is a {@link #isSelectQuery() SELECT} query. * * @author Diego Krupitza * @author Greg Turnquist - * @since 2.7.0 + * @author Mark Paluch + * @since 2.7 */ public interface QueryEnhancer { /** - * Adds {@literal order by} clause to the JPQL query. Uses the first alias to bind the sorting property to. + * Creates a new {@link QueryEnhancer} for a {@link DeclaredQuery}. Convenience method for + * {@link QueryEnhancerFactory#create(QueryProvider)}. * - * @param sort the sort specification to apply. - * @return the modified query string. + * @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); + } + + /** + * @return whether the underlying query is a SELECT query. + * @since 4.0 */ - default String applySorting(Sort sort) { - return applySorting(sort, detectAlias()); + default boolean isSelectQuery() { + return true; } /** - * Adds {@literal order by} clause to the JPQL query. + * Returns whether the given JPQL query contains a constructor expression. * - * @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. + * @return whether the given JPQL query contains a constructor expression. */ - String applySorting(Sort sort, @Nullable String alias); + 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(); /** - * Creates a count projected query from the given original query. + * Returns the projection part of the query, i.e. everything between {@code select} and {@code from}. * - * @return Guaranteed to be not {@literal null}. + * @return the projection part of the query. */ - default String createCountQueryFor() { - return createCountQueryFor(null); - } + String getProjection(); /** - * Creates a count projected query from the given original query using the provided countProjection. + * Gets the query we want to use for enhancements. * - * @param countProjection may be {@literal null}. - * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}. + * @return non-null {@link DeclaredQuery} that wraps the query. */ - String createCountQueryFor(@Nullable String countProjection); + QueryProvider getQuery(); /** - * Returns whether the given JPQL query contains a constructor expression. + * Rewrite the query to include sorting and apply {@link ReturnedType} customizations. * - * @return whether the given JPQL query contains a constructor expression. + * @param rewriteInformation the rewrite information to apply. + * @return the modified query string. + * @since 4.0 + * @throws IllegalStateException if the underlying query is not a {@link #isSelectQuery() SELECT} query. */ - default boolean hasConstructorExpression() { - return QueryUtils.hasConstructorExpression(getQuery().getQueryString()); - } + String rewrite(QueryRewriteInformation rewriteInformation); /** - * Returns the projection part of the query, i.e. everything between {@code select} and {@code from}. + * Creates a count projected query from the given original query using the provided {@code countProjection}. * - * @return the projection part of the query. + * @param countProjection may be {@literal null}. + * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}. + * @throws IllegalStateException if the underlying query is not a {@link #isSelectQuery() SELECT} query. */ - String getProjection(); - - Set getJoinAliases(); + String createCountQueryFor(@Nullable String countProjection); /** - * Gets the query we want to use for enhancements. + * Interface to describe the information needed to rewrite a query. * - * @return non-null {@link DeclaredQuery} that wraps the query + * @since 4.0 */ - DeclaredQuery getQuery(); + interface QueryRewriteInformation { + + /** + * @return the sort specification to apply. + */ + Sort getSort(); + + /** + * @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 JSqlParser + * if it is available from the class path. + * + * @return a {@link QueryEnhancerFactory} that uses JSqlParser. + * @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 f7c6f7dd1c..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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -15,71 +15,41 @@ */ 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; - /** - * 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 - * @since 2.7.0 + * @author Christoph Strobl + * @since 4.0 */ -public final class QueryEnhancerFactory { - - private static final Log LOG = LogFactory.getLog(QueryEnhancerFactory.class); - - private 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"); - } +public interface QueryEnhancerFactory { - 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."); - } - - } + /** + * Returns whether this QueryEnhancerFactory supports the given {@link DeclaredQuery}. + * + * @param query the query to be enhanced and introspected. + * @return {@code true} if this QueryEnhancer supports the given query; {@code false} otherwise. + */ + boolean supports(DeclaredQuery query); - private QueryEnhancerFactory() {} + /** + * Creates a new {@link QueryEnhancer} for the given query. + * + * @param query the query to be enhanced and introspected. + * @return the query enhancer to be used. + */ + QueryEnhancer create(QueryProvider query); /** - * Creates a new {@link QueryEnhancer} for the given {@link DeclaredQuery}. + * Creates a new {@link QueryEnhancerFactory} for the given {@link DeclaredQuery}. * * @param query must not be {@literal null}. * @return an implementation of {@link QueryEnhancer} that suits the query the most */ - public static QueryEnhancer forQuery(DeclaredQuery query) { - - if (query.isNativeQuery()) { - - if (jSqlParserPresent) { - /* - * If JSqlParser fails, throw some alert signaling that people should write a custom Impl. - */ - return new JSqlParserQueryEnhancer(query); - } - - return new DefaultQueryEnhancer(query); - } - - if (PersistenceProvider.HIBERNATE.isPresent()) { - return JpaQueryEnhancer.forHql(query); - } else if (PersistenceProvider.ECLIPSELINK.isPresent()) { - return JpaQueryEnhancer.forEql(query); - } else { - return JpaQueryEnhancer.forJpql(query); - } + 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}. + *

+ * 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 new file mode 100644 index 0000000000..6d7b6c5c97 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformation.java @@ -0,0 +1,120 @@ +/* + * 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 org.jspecify.annotations.Nullable; + +/** + * Value object capturing introspection details of a parsed query. + * + * @author Mark Paluch + * @author Soomin Kim + * @since 3.5 + */ +class QueryInformation { + + private final @Nullable String alias; + private final List projection; + private final boolean hasConstructorExpression; + private final StatementType statementType; + + QueryInformation(QueryInformationHolder introspection) { + this.alias = introspection.getAlias(); + this.projection = introspection.getProjection(); + this.hasConstructorExpression = introspection.hasConstructorExpression(); + this.statementType = introspection.getStatementType(); + } + + /** + * Primary table alias. Contains the first table name/table alias in case multiple tables are specified in the query. + * + * @return + */ + public @Nullable String getAlias() { + return alias; + } + + /** + * @return the primary selection. + */ + public List getProjection() { + return projection; + } + + /** + * @return {@code true} if the query uses a constructor expression. + */ + public boolean hasConstructorExpression() { + return hasConstructorExpression; + } + + /** + * @return the statement type of the query. + * @since 4.0 + */ + public StatementType getStatementType() { + return statementType; + } + + /** + * @return {@code true} if the query is a SELECT statement. + * @since 4.0 + */ + public boolean isSelectStatement() { + return statementType == StatementType.SELECT; + } + + /** + * Enum representing the type of SQL/JPQL statement. + * + * @since 4.0 + */ + enum StatementType { + + /** + * SELECT statement. + */ + SELECT, + + /** + * INSERT statement. + */ + INSERT, + + /** + * UPDATE statement. + */ + UPDATE, + + /** + * DELETE statement. + */ + DELETE, + + /** + * MERGE statement. + */ + MERGE, + + /** + * Other statement types. + */ + OTHER + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformationHolder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformationHolder.java new file mode 100644 index 0000000000..16fcac6b4e --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformationHolder.java @@ -0,0 +1,105 @@ +/* + * 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.springframework.data.jpa.repository.query.QueryTokens.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +/** + * Stateful object capturing introspection details of a parsed query. Introspection captures the first occurrence of a + * primary alias or projection items. + * + * @author Mark Paluch + * @since 4.0 + */ +class QueryInformationHolder { + + private @Nullable String primaryFromAlias = null; + private @Nullable List projection; + private boolean projectionProcessed; + private boolean hasConstructorExpression = false; + private QueryInformation.@Nullable StatementType statementType; + + public @Nullable String getAlias() { + return primaryFromAlias; + } + + public void capturePrimaryAlias(String alias) { + + if (primaryFromAlias != null) { + return; + } + + this.primaryFromAlias = alias; + } + + public boolean hasConstructorExpression() { + return hasConstructorExpression; + } + + public void constructorExpressionPresent() { + this.hasConstructorExpression = true; + } + + public QueryInformation.StatementType getStatementType() { + return statementType == null ? QueryInformation.StatementType.OTHER : statementType; + } + + public void setStatementType(QueryInformation.StatementType statementType) { + if (this.statementType == null) { + this.statementType = statementType; + } + } + + public List getProjection() { + return projection == null ? List.of() : List.copyOf(projection); + } + + /** + * Capture projection items if not already captured. + * + * @param selections collection of the selection items. + * @param tokenStreamFunction function that translates a selection item into a {@link QueryTokenStream} (i.e. a + * renderer). + */ + public void captureProjection(Collection selections, Function tokenStreamFunction) { + + if (projectionProcessed) { + return; + + } + List selectItemTokens = new ArrayList<>(selections.size() * 2); + + for (C selection : selections) { + + if (!selectItemTokens.isEmpty()) { + selectItemTokens.add(TOKEN_COMMA); + } + + selectItemTokens.add(QueryTokens.token(QueryRenderer.from(tokenStreamFunction.apply(selection)).render())); + } + + projection = selectItemTokens; + projectionProcessed = true; + } + +} 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 a05a34052b..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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -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 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 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 valueExtractor, - Parameter parameter, @Nullable TemporalType temporalType) { + private NamedOrIndexedQueryParameterSetter(Function 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) 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) 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 valueExtractor; + private final Parameter parameter; + private final TemporalType temporalType; + + private TemporalParameterSetter(Function 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) parameter, value)); - } else if (query.hasNamedParameters() && parameter.getName() != null) { - errorHandling.execute(() -> query.setParameter(parameter.getName(), value)); + if (parameter instanceof ParameterExpression) { + query.setParameter((Parameter) 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 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 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 9e27184582..b97c39da5b 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -18,22 +18,22 @@ 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; +import org.springframework.data.expression.ValueExpressionParser; 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.repository.query.QueryMethodEvaluationContextProvider; import org.springframework.data.spel.EvaluationContextProvider; -import org.springframework.expression.EvaluationContext; import org.springframework.expression.Expression; import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -47,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> 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(); } /** @@ -85,18 +93,12 @@ 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(SpelExpressionParser parser, - QueryMethodEvaluationContextProvider evaluationContextProvider, Parameters parameters) { - - Assert.notNull(parser, "SpelExpressionParser must not be null"); - Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null"); - Assert.notNull(parameters, "Parameters must not be null"); - - return new ExpressionBasedQueryParameterSetterFactory(parser, evaluationContextProvider, parameters); + static QueryParameterSetterFactory parsing(ValueExpressionParser parser, + ValueEvaluationContextProvider evaluationContextProvider) { + return new ExpressionBasedQueryParameterSetterFactory(parser, evaluationContextProvider); } /** @@ -107,19 +109,18 @@ static QueryParameterSetterFactory parsing(SpelExpressionParser parser, * @param binding the binding of the query parameter to be set. * @param parameter the method parameter to bind. */ - private static QueryParameterSetter createSetter(Function valueExtractor, + private static QueryParameterSetter createSetter(Function 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 parameters, String name) { + static @Nullable JpaParameter findParameterForBinding(Parameters parameters, String name) { JpaParameters bindableParameters = parameters.getBindableParameters(); @@ -161,38 +162,31 @@ static JpaParameter findParameterForBinding(Parameters parameters; + private final ValueExpressionParser parser; + private final ValueEvaluationContextProvider evaluationContextProvider; /** * @param parser must not be {@literal null}. * @param evaluationContextProvider must not be {@literal null}. - * @param parameters must not be {@literal null}. */ - ExpressionBasedQueryParameterSetterFactory(SpelExpressionParser parser, - QueryMethodEvaluationContextProvider evaluationContextProvider, Parameters parameters) { + ExpressionBasedQueryParameterSetterFactory(ValueExpressionParser parser, + ValueEvaluationContextProvider evaluationContextProvider) { - Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null"); - Assert.notNull(parser, "SpelExpressionParser must not be null"); - Assert.notNull(parameters, "Parameters must not be null"); + Assert.notNull(parser, "ValueExpressionParser must not be null"); + Assert.notNull(evaluationContextProvider, "ValueEvaluationContextProvider must not be null"); - this.evaluationContextProvider = evaluationContextProvider; this.parser = parser; - this.parameters = parameters; + 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)) { + if (!(binding.getOrigin() instanceof ParameterBinding.Expression e)) { return null; } - Expression expression = parser.parseExpression(e.expression()); - - return createSetter(values -> evaluateExpression(expression, values), binding, null); + return createSetter(values -> evaluateExpression(e.expression(), values), binding, null); } /** @@ -202,12 +196,29 @@ public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery decla * @param accessor must not be {@literal null}. * @return the result of the evaluation. */ - @Nullable - private Object evaluateExpression(Expression 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 { - EvaluationContext context = evaluationContextProvider.getEvaluationContext(parameters, accessor.getValues()); + @Override + public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery query) { - return expression.getValue(context, Object.class); + if (!(binding.getOrigin() instanceof ParameterBinding.Synthetic s)) { + return null; + } + + return createSetter(values -> s.value(), binding, null); } } @@ -222,33 +233,39 @@ private Object evaluateExpression(Expression expression, JpaParametersParameterA 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; + if (!(binding.getOrigin() instanceof MethodInvocationArgument mia)) { + return null; } BindingIdentifier identifier = mia.identifier(); + JpaParameter parameter; - if (declaredQuery.hasNamedParameter()) { + if (preferNamedParameters && identifier.hasName()) { parameter = findParameterForBinding(parameters, identifier.getName()); - } else { + } else if (identifier.hasPosition()) { parameter = findParameterForBinding(parameters, identifier.getPosition() - 1); + } else { + // this can happen when a query uses parameters in ORDER BY and the COUNT query just needs to drop a binding. + parameter = null; } return parameter == null // @@ -256,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); } } @@ -265,60 +281,35 @@ 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; - /** - * 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> 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) { - - int parameterIndex = binding.getRequiredPosition() - 1; + public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery query) { - 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 (binding instanceof ParameterBinding.PartTreeParameterBinding ptb) { - ParameterMetadata metadata = parameterMetadata.get(parameterIndex); + if (ptb.isIsNullParameter()) { + return QueryParameterSetter.NOOP; + } - 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); - } + if (binding instanceof ParameterMetadataProvider.ScoreParameterBinding) { + return super.create(binding, query); + } - @Nullable - private Object getAndPrepare(JpaParameter parameter, ParameterMetadata metadata, - JpaParametersParameterAccessor accessor) { - return metadata.prepare(accessor.getValue(parameter)); + return null; } } @@ -346,15 +337,13 @@ public ParameterImpl(BindingIdentifier identifier, Class 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; } @@ -362,7 +351,6 @@ public Integer getPosition() { public Class 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 a2013af402..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-2024 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 new file mode 100644 index 0000000000..2b7109914d --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRenderer.java @@ -0,0 +1,733 @@ +/* + * 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.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Stream; + +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; +import org.springframework.util.CompositeIterator; + +/** + * Abstraction to encapsulate query expressions and render a query. + *

+ * Query rendering consists of multiple building blocks: + *

    + *
  • {@link QueryTokens.SimpleQueryToken tokens} and {@link QueryTokens.ExpressionToken expression tokens}
  • + *
  • {@link QueryRenderer compositions} such as a composition of multiple tokens.
  • + *
  • {@link QueryRenderer expressions} that are individual parts such as {@code SELECT} or {@code ORDER BY …}
  • + *
  • {@link QueryRenderer inline expressions} such as composition of tokens and expressions such as function calls + * with parenthesis {@code SOME_FUNCTION(ARGS)}
  • + *
+ * + * @author Mark Paluch + * @author Christoph Strobl + */ +abstract class QueryRenderer implements QueryTokenStream { + + /** + * Creates a QueryRenderer from a collection of {@link QueryToken}. + */ + static QueryRenderer from(Collection tokens) { + List tokensToUse = new ArrayList<>(Math.max(tokens.size(), 32)); + tokensToUse.addAll(tokens); + return new TokenRenderer(tokensToUse); + } + + /** + * Creates a QueryRenderer from a {@link QueryTokenStream}. + */ + static QueryRenderer from(QueryTokenStream tokens) { + + if (tokens instanceof QueryRendererBuilder builder) { + tokens = builder.current; + } + + if (tokens instanceof QueryRenderer renderer) { + return renderer; + } + + return new QueryStreamRenderer(tokens); + } + + /** + * Creates a new empty {@link QueryRenderer}. + */ + public static QueryRenderer empty() { + return EmptyQueryRenderer.INSTANCE; + } + + /** + * Creates a new {@link QueryRendererBuilder}. + */ + static QueryRendererBuilder builder() { + return new QueryRendererBuilder(); + } + + /** + * @return the rendered query. + */ + abstract String render(); + + /** + * @return the rendered query. + */ + static String render(Iterable tokenStream) { + + if (tokenStream instanceof QueryRendererBuilder qrb) { + tokenStream = qrb.current; + } + + if (tokenStream instanceof QueryRenderer qr) { + return qr.render(); + } + + StringBuilder results = null; + boolean previousExpression = false; + + Iterator iterator = tokenStream.iterator(); + while (iterator.hasNext()) { + QueryToken token = iterator.next(); + + if (results == null) { + if (iterator.hasNext()) { + results = new StringBuilder(); + } else { + return token.value(); + } + } + + if (previousExpression) { + if (!results.isEmpty() && results.charAt(results.length() - 1) != ' ') { + results.append(' '); + } + } + + previousExpression = token.isExpression(); + results.append(token.value()); + } + + return results != null ? results.toString() : ""; + } + + /** + * Append a {@link QueryRenderer} to create a composed renderer. + */ + QueryRenderer append(QueryTokenStream tokens) { + + if (tokens instanceof QueryRendererBuilder builder) { + tokens = builder.current; + } + + if (tokens instanceof QueryRenderer qr) { + + if (isEmpty()) { + return qr; + } + + return CompositeRenderer.combine(this, qr); + } + + if (isEmpty()) { + return QueryRenderer.from(tokens); + } + + return CompositeRenderer.combine(this, QueryRenderer.from(tokens)); + } + + @Override + public String toString() { + return render(); + } + + public static QueryRenderer ofExpression(QueryTokenStream tokenStream) { + + if (tokenStream instanceof ExpressionRenderer er) { + return er; + } + + if (tokenStream instanceof QueryRendererBuilder builder) { + tokenStream = builder.current; + } + + if (tokenStream.isEmpty()) { + return EmptyQueryRenderer.INSTANCE; + } + + if (!(tokenStream instanceof QueryRenderer)) { + tokenStream = QueryRenderer.from(tokenStream); + } + + if (tokenStream.isExpression()) { + return (QueryRenderer) tokenStream; + } + + return new ExpressionRenderer((QueryRenderer) tokenStream); + } + + public static QueryRenderer inline(QueryTokenStream tokenStream) { + + Assert.notNull(tokenStream, "QueryTokenStream must not be null!"); + + if (tokenStream instanceof InlineRenderer ilr) { + return ilr; + } + + if (tokenStream instanceof QueryRendererBuilder builder) { + tokenStream = builder.current; + } + + if (tokenStream.isEmpty()) { + return EmptyQueryRenderer.INSTANCE; + } + + if (!(tokenStream instanceof QueryRenderer)) { + tokenStream = QueryRenderer.from(tokenStream); + } + + return new InlineRenderer((QueryRenderer) tokenStream); + } + + /** + * Composed renderer consisting of one or more QueryRenderers. + */ + static class CompositeRenderer extends QueryRenderer { + + private final List nested; + + static CompositeRenderer combine(QueryRenderer root, QueryRenderer nested) { + + List queryRenderers = new ArrayList<>(32); + queryRenderers.add(root); + queryRenderers.add(nested); + + return new CompositeRenderer(queryRenderers); + } + + private CompositeRenderer(List nested) { + this.nested = nested; + } + + @Override + String render() { + + StringBuilder builder = new StringBuilder(64); + String lastAppended = null; + + boolean lastExpression = false; + for (QueryRenderer queryRenderer : nested) { + + if (lastAppended != null && (lastExpression || queryRenderer.isExpression()) && !builder.isEmpty() + && (!lastAppended.endsWith(" ") && !lastAppended.endsWith("("))) { + builder.append(' '); + } + + lastAppended = queryRenderer.render(); + builder.append(lastAppended); + lastExpression = queryRenderer.isExpression(); + } + + return builder.toString(); + } + + /** + * Append a {@link QueryRenderer} to create a composed renderer. + */ + QueryRenderer append(QueryTokenStream tokens) { + + if (tokens instanceof QueryRendererBuilder builder) { + tokens = builder.current; + } + + if (tokens instanceof QueryRenderer qr) { + + if (isEmpty()) { + return this; + } + + if (qr.isEmpty()) { + return qr; + } + + if (tokens instanceof CompositeRenderer cr) { + this.nested.addAll(cr.nested); + + return this; + } + + } + + return super.append(tokens); + } + + @Override + public @Nullable QueryToken getLast() { + + for (int i = nested.size() - 1; i > -1; i--) { + + QueryRenderer renderer = nested.get(i); + + if (!renderer.isEmpty()) { + return renderer.getLast(); + } + } + + return null; + } + + @Override + public Iterator iterator() { + + CompositeIterator iterator = new CompositeIterator<>(); + for (QueryTokenStream stream : nested) { + iterator.add(stream.iterator()); + } + return iterator; + } + + @Override + public boolean isEmpty() { + + for (QueryRenderer renderer : nested) { + if (!renderer.isEmpty()) { + return false; + } + } + + return true; + } + + @Override + public int size() { + + int size = 0; + + for (QueryTokenStream stream : nested) { + size += stream.size(); + } + return size; + } + + @Override + public boolean isExpression() { + return !nested.isEmpty() && nested.get(nested.size() - 1).isExpression(); + } + + } + + /** + * Renderer using {@link QueryTokens.SimpleQueryToken}. + */ + static class TokenRenderer extends QueryRenderer { + + private final List tokens; + + TokenRenderer(List tokens) { + this.tokens = tokens; + } + + @Override + String render() { + return render(tokens); + } + + @Override + public Stream stream() { + return tokens.stream(); + } + + @Override + public Iterator iterator() { + return tokens.iterator(); + } + + @Override + public List toList() { + return tokens; + } + + @Override + public @Nullable QueryToken getFirst() { + return tokens.isEmpty() ? null : tokens.get(0); + } + + @Override + public @Nullable QueryToken getLast() { + return tokens.isEmpty() ? null : tokens.get(tokens.size() - 1); + } + + @Override + public int size() { + return tokens.size(); + } + + @Override + public boolean isEmpty() { + return tokens.isEmpty(); + } + + @Override + public boolean isExpression() { + return !tokens.isEmpty() && getRequiredLast().isExpression(); + } + + } + + static class QueryStreamRenderer extends QueryRenderer { + + private final QueryTokenStream tokens; + + public QueryStreamRenderer(QueryTokenStream tokens) { + this.tokens = tokens; + } + + @Override + String render() { + return render(tokens); + } + + @Override + public Iterator iterator() { + return tokens.iterator(); + } + + @Override + public @Nullable QueryToken getFirst() { + return tokens.getFirst(); + } + + @Override + public @Nullable QueryToken getLast() { + return tokens.getLast(); + } + + @Override + public int size() { + return tokens.size(); + } + + @Override + public boolean isEmpty() { + return tokens.isEmpty(); + } + + @Override + public boolean isExpression() { + return !tokens.isEmpty() && tokens.getRequiredLast().isExpression(); + } + } + + /** + * Builder for {@link QueryRenderer}. + */ + static class QueryRendererBuilder implements QueryTokenStream { + + protected QueryRenderer current = QueryRenderer.empty(); + + /** + * Append a collection of {@link QueryToken}s. + * + * @param tokens + * @return {@code this} builder. + */ + QueryRendererBuilder append(List tokens) { + return append(QueryRenderer.from(tokens)); + } + + /** + * Append a QueryRenderer. + * + * @param stream + * @return {@code this} builder. + */ + QueryRendererBuilder append(QueryTokenStream stream) { + + if (stream.isEmpty()) { + return this; + } + + current = current.append(stream); + + return this; + } + + /** + * Append a QueryRenderer inline. + * + * @param stream + * @return {@code this} builder. + */ + QueryRendererBuilder appendInline(QueryTokenStream stream) { + + if (stream.isEmpty()) { + return this; + } + + current = current.append(QueryRenderer.inline(stream)); + + return this; + } + + /** + * Append a QueryRendererBuilder as expression. + * + * @param builder + * @return {@code this} builder. + */ + QueryRendererBuilder appendExpression(QueryRendererBuilder builder) { + return appendExpression(builder.current); + } + + /** + * Append a QueryRenderer as expression. + * + * @param tokens + * @return {@code this} builder. + */ + QueryRendererBuilder appendExpression(QueryTokenStream tokens) { + + if (tokens.isEmpty()) { + return this; + } + + current = current.append(QueryRenderer.ofExpression(tokens)); + + return this; + } + + @Override + public List toList() { + return current.toList(); + } + + @Override + public Stream stream() { + return current.stream(); + } + + @Override + public @Nullable QueryToken getFirst() { + return current.getFirst(); + } + + @Override + public @Nullable QueryToken getLast() { + return current.getLast(); + } + + @Override + public boolean isExpression() { + return current.isExpression(); + } + + @Override + public boolean isEmpty() { + return current.isEmpty(); + } + + @Override + public int size() { + return current.size(); + } + + @Override + public Iterator iterator() { + return current.iterator(); + } + + @Override + public String toString() { + return current.render(); + } + + public QueryRenderer build() { + return current; + } + + public QueryRenderer toInline() { + return new InlineRenderer(current); + } + } + + private static class InlineRenderer extends QueryRenderer { + + private final QueryRenderer delegate; + + private InlineRenderer(QueryRenderer delegate) { + this.delegate = delegate; + } + + @Override + String render() { + return delegate.render(); + } + + @Override + public Stream stream() { + return delegate.stream(); + } + + @Override + public List toList() { + return delegate.toList(); + } + + @Override + public Iterator iterator() { + return delegate.iterator(); + } + + @Override + public @Nullable QueryToken getFirst() { + return delegate.getFirst(); + } + + @Override + public @Nullable QueryToken getLast() { + return delegate.getLast(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public int size() { + return delegate.size(); + } + + @Override + public boolean isExpression() { + return false; + } + } + + private static class ExpressionRenderer extends QueryRenderer { + + private final QueryRenderer delegate; + + private ExpressionRenderer(QueryRenderer delegate) { + this.delegate = delegate; + } + + @Override + String render() { + return delegate.render(); + } + + @Override + public Stream stream() { + return delegate.stream(); + } + + @Override + public List toList() { + return delegate.toList(); + } + + @Override + public Iterator iterator() { + return delegate.iterator(); + } + + @Override + public @Nullable QueryToken getFirst() { + return delegate.getFirst(); + } + + @Override + public @Nullable QueryToken getLast() { + return delegate.getLast(); + } + + @Override + public boolean isEmpty() { + return delegate.isEmpty(); + } + + @Override + public int size() { + return delegate.size(); + } + + @Override + public boolean isExpression() { + return true; + } + + } + + private static class EmptyQueryRenderer extends QueryRenderer { + + public static final QueryRenderer INSTANCE = new EmptyQueryRenderer(); + + @Override + String render() { + return ""; + } + + @Override + QueryRenderer append(QueryTokenStream tokens) { + + if (tokens.isEmpty()) { + return this; + } + + if (tokens instanceof QueryRenderer qr) { + return qr; + } + + return QueryRenderer.from(tokens); + } + + @Override + public List toList() { + return Collections.emptyList(); + } + + @Override + public Stream stream() { + return Stream.empty(); + } + + @Override + public Iterator iterator() { + return Collections.emptyIterator(); + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public int size() { + return 0; + } + + @Override + public boolean isExpression() { + return false; + } + } +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRewriterProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRewriterProvider.java index b3abe97552..85e6d34aa2 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRewriterProvider.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRewriterProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryToken.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryToken.java new file mode 100644 index 0000000000..7cf9ccbcb3 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryToken.java @@ -0,0 +1,39 @@ +/* + * 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; + +/** + * Interface defining a token. Tokens are atomic elements from which queries are built. Tokens can be inline tokens that + * do not require spacing or expressions that must be separated by spaces, commas, etc. + * + * @author Christoph Strobl + * @since 3.4 + */ +interface QueryToken extends QueryTokenStream { + + /** + * @return the token value (i.e. its content). + */ + String value(); + + /** + * @return {@code true} if the token represents an expression. + */ + default boolean isExpression() { + return false; + } + +} 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 new file mode 100644 index 0000000000..c1b01daf14 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokenStream.java @@ -0,0 +1,280 @@ +/* + * 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.Collection; +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.function.Function; + +import org.antlr.v4.runtime.tree.ParseTree; +import org.antlr.v4.runtime.tree.TerminalNode; +import org.antlr.v4.runtime.tree.Tree; +import org.jspecify.annotations.Nullable; + +import org.springframework.data.util.Streamable; +import org.springframework.util.CollectionUtils; + +/** + * Stream of {@link QueryToken}. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 3.4 + */ +interface QueryTokenStream extends Streamable { + + /** + * Creates an empty stream. + */ + static QueryTokenStream empty() { + return EmptyQueryTokenStream.INSTANCE; + } + + /** + * Compose a {@link QueryTokenStream} from a collection of inline elements. + * + * @param elements collection of elements. + * @param visitor visitor function converting the element into a {@link QueryTokenStream}. + * @param separator separator token. + * @return the composed token stream. + */ + static QueryTokenStream concat(Collection elements, Function visitor, + QueryToken separator) { + return concat(elements, visitor, QueryRenderer::inline, separator); + } + + /** + * Compose a {@link QueryTokenStream} from a collection of expression elements. + * + * @param elements collection of elements. + * @param visitor visitor function converting the element into a {@link QueryTokenStream}. + * @param separator separator token. + * @return the composed token stream. + */ + static QueryTokenStream concatExpressions(Collection elements, Function visitor, + QueryToken separator) { + return concat(elements, visitor, QueryRenderer::ofExpression, separator); + } + + /** + * Compose a {@link QueryTokenStream} from a collection of elements. Expressions are rendered using space separators. + * + * @param elements collection of elements. + * @param visitor visitor function converting the element into a {@link QueryTokenStream}. + * @return the composed token stream. + * @since 4.0 + */ + static QueryTokenStream concatExpressions(Collection elements, Function visitor) { + + if (CollectionUtils.isEmpty(elements)) { + return QueryTokenStream.empty(); + } + + QueryRenderer.QueryRendererBuilder builder = QueryRenderer.builder(); + + for (T child : elements) { + + if (child instanceof TerminalNode tn) { + builder.append(QueryTokens.expression(tn)); + } else { + builder.appendExpression(visitor.apply(child)); + } + } + + return builder.build(); + } + + /** + * Compose a {@link QueryTokenStream} from a collection of expressions from a {@link Tree}. Expressions are rendered + * using space separators. + * + * @param elements collection of elements. + * @param visitor visitor function converting the element into a {@link QueryTokenStream}. + * @return the composed token stream. + * @since 4.0 + */ + static QueryTokenStream concatExpressions(Tree elements, Function visitor) { + + int childCount = elements.getChildCount(); + if (childCount == 0) { + return QueryTokenStream.empty(); + } + + QueryRenderer.QueryRendererBuilder builder = QueryRenderer.builder(); + + for (int i = 0; i < childCount; i++) { + + Tree child = elements.getChild(i); + if (child instanceof TerminalNode tn) { + builder.append(QueryTokens.expression(tn)); + } else if (child instanceof ParseTree pt) { + builder.appendExpression(visitor.apply(pt)); + } else { + throw new IllegalArgumentException("Unsupported child type: " + child); + } + } + + return builder.build(); + } + + /** + * Compose a {@link QueryTokenStream} from a collection of elements. + * + * @param elements collection of elements. + * @param visitor visitor function converting the element into a {@link QueryTokenStream}. + * @param separator separator token. + * @param postProcess post-processing function to map {@link QueryTokenStream}. + * @return the composed token stream. + */ + static QueryTokenStream concat(Collection elements, Function visitor, + Function postProcess, QueryToken separator) { + + QueryRenderer.QueryRendererBuilder builder = null; + QueryTokenStream firstElement = null; + for (T element : elements) { + + QueryTokenStream tokenStream = postProcess.apply(visitor.apply(element)); + + if (firstElement == null) { + firstElement = tokenStream; + continue; + } + + if (builder == null) { + builder = QueryRenderer.builder(); + builder.append(firstElement); + } + + if (!builder.isEmpty()) { + builder.append(separator); + } + builder.append(tokenStream); + } + + if (builder != null) { + return builder; + } + + if (firstElement != null) { + return firstElement; + } + + return QueryTokenStream.empty(); + } + + /** + * Creates a {@link QueryTokenStream} that groups the given {@link QueryTokenStream nested token stream} in + * parentheses ({@code (…)}). + * + * @param nested the nested token stream to wrap in parentheses. + * @return a {@link QueryTokenStream} that groups the given {@link QueryTokenStream nested token stream} in + * parentheses. + * @since 5.0 + */ + static QueryTokenStream group(QueryTokenStream nested) { + + QueryRenderer.QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); + + return builder.build(); + } + + /** + * Creates a {@link QueryTokenStream} representing a function call including arguments wrapped in parentheses. + * + * @param functionName function name. + * @param arguments the arguments of the function call. + * @return a {@link QueryTokenStream} representing a function call. + * @since 5.0 + */ + static QueryTokenStream ofFunction(TerminalNode functionName, QueryTokenStream arguments) { + + QueryRenderer.QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(QueryTokens.token(functionName)); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(arguments); + builder.append(TOKEN_CLOSE_PAREN); + + return builder.build(); + } + + /** + * @return the first query token or {@code null} if empty. + */ + default @Nullable QueryToken getFirst() { + + Iterator 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. + */ + 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. + */ + boolean isExpression(); + + /** + * @return the number of tokens. + */ + int size(); + + /** + * @return {@code true} if this stream contains no tokens. + */ + boolean isEmpty(); + +} 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 new file mode 100644 index 0000000000..8d6a76e74f --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java @@ -0,0 +1,185 @@ +/* + * 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.Collections; +import java.util.Iterator; +import java.util.function.Supplier; + +import org.antlr.v4.runtime.Token; +import org.antlr.v4.runtime.tree.TerminalNode; + +/** + * Utility class to create query tokens. + * + * @author Mark Paluch + * @since 3.4 + */ +class QueryTokens { + + /** + * Commonly use tokens. + */ + static final QueryToken EMPTY_TOKEN = token(""); + static final QueryToken TOKEN_COMMA = token(", "); + static final QueryToken TOKEN_SPACE = token(" "); + static final QueryToken TOKEN_DOT = token("."); + static final QueryToken TOKEN_EQUALS = token(" = "); + static final QueryToken TOKEN_OPEN_PAREN = token("("); + static final QueryToken TOKEN_CLOSE_PAREN = token(")"); + static final QueryToken TOKEN_NEW = expression("new"); + static final QueryToken TOKEN_ORDER_BY = expression("order by"); + static final QueryToken TOKEN_LOWER_FUNC = token("lower("); + static final QueryToken TOKEN_SELECT_COUNT = token("select count("); + static final QueryToken TOKEN_COUNT_FUNC = token("count("); + static final QueryToken TOKEN_DOUBLE_PIPE = token(" || "); + static final QueryToken TOKEN_OPEN_SQUARE_BRACKET = token("["); + static final QueryToken TOKEN_CLOSE_SQUARE_BRACKET = token("]"); + static final QueryToken TOKEN_COLON = token(":"); + static final QueryToken TOKEN_DASH = token("-"); + static final QueryToken TOKEN_QUESTION_MARK = token("?"); + static final QueryToken TOKEN_OPEN_BRACE = token("{"); + static final QueryToken TOKEN_CLOSE_BRACE = token("}"); + static final QueryToken TOKEN_DOUBLE_UNDERSCORE = token("__"); + static final QueryToken TOKEN_AS = expression("AS"); + static final QueryToken TOKEN_DESC = expression("desc"); + static final QueryToken TOKEN_ASC = expression("asc"); + static final QueryToken TOKEN_WITH = expression("WITH"); + static final QueryToken TOKEN_NOT = expression("NOT"); + static final QueryToken TOKEN_MATERIALIZED = expression("materialized"); + + /** + * Creates a {@link QueryToken token} from an ANTLR {@link TerminalNode}. + */ + static QueryToken token(TerminalNode node) { + return token(node.getText()); + } + + /** + * Creates a {@link QueryToken token} from an ANTLR {@link Token}. + */ + static QueryToken token(Token token) { + return token(token.getText()); + } + + /** + * Creates a {@link QueryToken token} from a string {@code token}. + */ + static QueryToken token(String token) { + return new SimpleQueryToken(token); + } + + /** + * Creates a {@link QueryToken expression} from an ANTLR {@link TerminalNode}. + */ + static QueryToken expression(TerminalNode node) { + return expression(node.getText()); + } + + /** + * Creates a {@link QueryToken expression} from an ANTLR {@link Token}. + */ + static QueryToken expression(Token token) { + return expression(token.getText()); + } + + /** + * Creates a {@link QueryToken token} from a string {@code expression}. + */ + static QueryToken expression(String expression) { + return new ExpressionToken(expression); + } + + /** + * A value type used to represent a JPA query token. NOTE: Sometimes the token's value is based upon a value found + * later in the parsing process, so the text itself is wrapped in a {@link Supplier}. + * + * @author Greg Turnquist + * @author Christoph Strobl + * @since 3.1 + */ + static class SimpleQueryToken implements QueryToken, QueryTokenStream { + + /** + * The text value of the token. + */ + private final String token; + + SimpleQueryToken(String token) { + this.token = token; + } + + public String value() { + return token; + } + + @Override + public final boolean equals(Object object) { + if (this == object) { + return true; + } + + if (!(object instanceof QueryToken that)) { + return false; + } + + return value().equalsIgnoreCase(that.value()); + } + + @Override + public int size() { + return 1; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public Iterator iterator() { + return Collections.singleton((QueryToken) this).iterator(); + } + + @Override + public boolean isExpression() { + return false; + } + + @Override + public int hashCode() { + return value().hashCode(); + } + + @Override + public String toString() { + return value(); + } + } + + static class ExpressionToken extends SimpleQueryToken { + + ExpressionToken(String token) { + super(token); + } + + @Override + public boolean isExpression() { + return true; + } + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTransformers.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTransformers.java index 368e9a95b2..1a0372d9f5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTransformers.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTransformers.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 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,9 +15,10 @@ */ package org.springframework.data.jpa.repository.query; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; +import static org.springframework.data.jpa.repository.query.QueryTokens.*; import java.util.ArrayList; +import java.util.Iterator; import java.util.List; /** @@ -28,33 +29,113 @@ */ class QueryTransformers { - /** - * Filter a token list from a {@code SELECT} clause to be used within a count query. That is, filter any {@code AS …} - * aliases. - * - * @param selection the input selection. - * @return filtered selection to be used with count queries. - */ - static List filterCountSelection(List selection) { + static class CountSelectionTokenStream implements QueryTokenStream { - List target = new ArrayList<>(selection.size()); - boolean skipNext = false; + private final List tokens; + private final boolean requiresPrimaryAlias; - for (JpaQueryParsingToken token : selection) { + CountSelectionTokenStream(List tokens, boolean requiresPrimaryAlias) { + this.tokens = tokens; + this.requiresPrimaryAlias = requiresPrimaryAlias; + } + + static CountSelectionTokenStream create(QueryTokenStream selection) { + + List target = new ArrayList<>(selection.size()); + boolean skipNext = false; + boolean containsNew = false; + + for (QueryToken token : selection) { + + if (skipNext) { + skipNext = false; + continue; + } + + if (token.equals(TOKEN_AS)) { + skipNext = true; + continue; + } + + if (!token.equals(TOKEN_COMMA) && token.isExpression()) { + token = QueryTokens.token(token.value()); + } + + if (!containsNew && token.equals(TOKEN_NEW)) { + containsNew = true; + } - if (skipNext) { - skipNext = false; - continue; + target.add(token); } - if (token.isA(TOKEN_AS)) { - skipNext = true; - continue; + return new CountSelectionTokenStream(target, containsNew); + } + + /** + * Filter constructor expression and return the selection list of the constructor. + * + * @return the selection list of the constructor without {@code NEW}, class name, and the first level of + * parentheses. + * @since 3.5.2 + */ + public CountSelectionTokenStream withoutConstructorExpression() { + + if (!requiresPrimaryAlias()) { + return this; + } + + List target = new ArrayList<>(size()); + int nestingLevel = 0; + + for (QueryToken token : this) { + + if (token.equals(TOKEN_OPEN_PAREN)) { + nestingLevel++; + continue; + } + + if (token.equals(TOKEN_CLOSE_PAREN)) { + nestingLevel--; + continue; + } + + if (nestingLevel > 0) { + target.add(token); + } } - target.add(token); + + return new CountSelectionTokenStream(target, requiresPrimaryAlias()); + } + + @Override + public Iterator iterator() { + return tokens.iterator(); + } + + @Override + public List toList() { + return tokens; + } + + @Override + public int size() { + return tokens.size(); + } + + @Override + public boolean isExpression() { + return true; + } + + @Override + public boolean isEmpty() { + return tokens.isEmpty(); + } + + public boolean requiresPrimaryAlias() { + return requiresPrimaryAlias; } - return target; } } 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 0265b5549d..aee5df5cef 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -15,12 +15,9 @@ */ package org.springframework.data.jpa.repository.query; -import static jakarta.persistence.metamodel.Attribute.PersistentAttributeType.*; import static java.util.regex.Pattern.*; import jakarta.persistence.EntityManager; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToOne; import jakarta.persistence.Parameter; import jakarta.persistence.Query; import jakarta.persistence.criteria.CriteriaBuilder; @@ -29,39 +26,35 @@ 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; import jakarta.persistence.metamodel.Bindable; import jakarta.persistence.metamodel.ManagedType; -import jakarta.persistence.metamodel.PluralAttribute; -import jakarta.persistence.metamodel.SingularAttribute; -import java.lang.annotation.Annotation; -import java.lang.reflect.AnnotatedElement; -import java.lang.reflect.Member; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; -import org.springframework.core.annotation.AnnotationUtils; +import org.jspecify.annotations.Nullable; + import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; import org.springframework.data.jpa.domain.JpaSort.JpaOrder; +import org.springframework.data.jpa.provider.PersistenceProvider; 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; @@ -94,12 +87,14 @@ * @author Pranav HS * @author Eduard Dudar * @author Yanming Zhou + * @author Alim Naizabek + * @author Jakub Soltys */ public abstract class QueryUtils { public static final String COUNT_QUERY_STRING = "select count(%s) from %s x"; public static final String DELETE_ALL_QUERY_STRING = "delete from %s x"; - public static final String DELETE_ALL_QUERY_BY_ID_STRING = "delete from %s x where %s in :ids"; + public static final String DELETE_ALL_QUERY_BY_ID_STRING = "delete from %s x where x.%s in :ids"; // Used Regex/Unicode categories (see https://www.unicode.org/reports/tr18/#General_Category_Property): // Z Separator @@ -114,38 +109,35 @@ public abstract class QueryUtils { private static final String SIMPLE_COUNT_VALUE = "$2"; private static final String COMPLEX_COUNT_VALUE = "$3 $6"; private static final String COMPLEX_COUNT_LAST_VALUE = "$6"; - private static final Pattern ORDER_BY_PART = Pattern.compile("(?iu)\\s+order\\s+by\\s+.*", CASE_INSENSITIVE | DOTALL); + private static final Pattern ORDER_BY_PART = compile("(?iu)\\s+order\\s+by\\s+.*", CASE_INSENSITIVE | DOTALL); private static final Pattern ALIAS_MATCH; private static final Pattern COUNT_MATCH; - private static final Pattern STARTS_WITH_PAREN = Pattern.compile("^\\s*\\("); - private static final Pattern PARENS_TO_REMOVE = Pattern.compile("(\\(.*\\bfrom\\b[^)]+\\))", + private static final Pattern STARTS_WITH_PAREN = compile("^\\s*\\("); + private static final Pattern PARENS_TO_REMOVE = compile("(\\(.*\\bfrom\\b[^)]+\\))", CASE_INSENSITIVE | DOTALL | MULTILINE); - private static final Pattern PROJECTION_CLAUSE = Pattern.compile("select\\s+(?:distinct\\s+)?(.+)\\s+from", - Pattern.CASE_INSENSITIVE); + private static final Pattern PROJECTION_CLAUSE = compile("select\\s+(?:distinct\\s+)?(.+)\\s+from", CASE_INSENSITIVE); - private static final Pattern NO_DIGITS = Pattern.compile("\\D+"); + private static final Pattern NO_DIGITS = compile("\\D+"); private static final String JOIN = "join\\s+(fetch\\s+)?" + IDENTIFIER + "\\s+(as\\s+)?" + IDENTIFIER_GROUP; - private static final Pattern JOIN_PATTERN = Pattern.compile(JOIN, Pattern.CASE_INSENSITIVE); + private static final Pattern JOIN_PATTERN = compile(JOIN, CASE_INSENSITIVE); private static final String EQUALS_CONDITION_STRING = "%s.%s = :%s"; - private static final Pattern ORDER_BY = Pattern.compile("(order\\s+by\\s+)", CASE_INSENSITIVE); - private static final Pattern ORDER_BY_IN_WINDOW_OR_SUBSELECT = Pattern - .compile("\\([\\s\\S]*order\\s+by\\s[\\s\\S]*\\)", CASE_INSENSITIVE); + private static final Pattern ORDER_BY = compile("(order\\s+by\\s+)", CASE_INSENSITIVE); + private static final Pattern ORDER_BY_IN_WINDOW_OR_SUBSELECT = compile("\\([\\s\\S]*order\\s+by\\s[\\s\\S]*\\)", + CASE_INSENSITIVE); - private static final Pattern NAMED_PARAMETER = Pattern.compile(COLON_NO_DOUBLE_COLON + IDENTIFIER + "|#" + IDENTIFIER, + private static final Pattern NAMED_PARAMETER = compile(COLON_NO_DOUBLE_COLON + IDENTIFIER + "|#" + IDENTIFIER, CASE_INSENSITIVE); private static final Pattern CONSTRUCTOR_EXPRESSION; - private static final Map> ASSOCIATION_TYPES; - private static final int QUERY_JOIN_ALIAS_GROUP_INDEX = 3; private static final int VARIABLE_NAME_GROUP_INDEX = 4; private static final int COMPLEX_COUNT_FIRST_INDEX = 3; - private static final Pattern PUNCTATION_PATTERN = Pattern.compile(".*((?![._])[\\p{Punct}|\\s])"); + private static final Pattern PUNCTATION_PATTERN = compile(".*((?![._])[\\p{Punct}|\\s])"); private static final Pattern FUNCTION_PATTERN; private static final Pattern FIELD_ALIAS_PATTERN; @@ -175,15 +167,6 @@ public abstract class QueryUtils { COUNT_MATCH = compile(builder.toString(), CASE_INSENSITIVE | DOTALL); - Map> persistentAttributeTypes = new HashMap<>(); - persistentAttributeTypes.put(ONE_TO_ONE, OneToOne.class); - persistentAttributeTypes.put(ONE_TO_MANY, null); - persistentAttributeTypes.put(MANY_TO_ONE, ManyToOne.class); - persistentAttributeTypes.put(MANY_TO_MANY, null); - persistentAttributeTypes.put(ELEMENT_COLLECTION, null); - - ASSOCIATION_TYPES = Collections.unmodifiableMap(persistentAttributeTypes); - builder = new StringBuilder(); builder.append("select"); builder.append("\\s+"); // at least one space separating @@ -202,17 +185,15 @@ public abstract class QueryUtils { // any function call including parameters within the brackets builder.append("\\w+\\s*\\([\\w\\.,\\s'=:;\\\\?]+\\)"); // the potential alias - builder.append("\\s+[as|AS]+\\s+(([\\w\\.]+))"); + builder.append("\\s+(?:as)+\\s+([\\w\\.]+)"); - FUNCTION_PATTERN = compile(builder.toString()); + FUNCTION_PATTERN = compile(builder.toString(), CASE_INSENSITIVE); builder = new StringBuilder(); - builder.append("\\s+"); // at least one space builder.append("[^\\s\\(\\)]+"); // No white char no bracket - builder.append("\\s+[as|AS]+\\s+(([\\w\\.]+))"); // the potential alias - - FIELD_ALIAS_PATTERN = compile(builder.toString()); + builder.append("\\s+(?:as)+\\s+([\\w\\.]+)"); // the potential alias + FIELD_ALIAS_PATTERN = compile(builder.toString(), CASE_INSENSITIVE); } /** @@ -292,11 +273,10 @@ public static String applySorting(String query, Sort sort, @Nullable String alia Set selectionAliases = getFunctionAliases(query); selectionAliases.addAll(getFieldAliases(query)); - for (Order order : sort) { - builder.append(getOrderClause(joinAliases, selectionAliases, alias, order)).append(", "); - } + String orderClauses = sort.stream().map(order -> getOrderClause(joinAliases, selectionAliases, alias, order)) + .collect(Collectors.joining(", ")); - builder.delete(builder.length() - 2, builder.length()); + builder.append(orderClauses); return builder.toString(); } @@ -399,7 +379,7 @@ static Set getOuterJoinAliases(String query) { * @param query a {@literal String} containing a query. Must not be {@literal null}. * @return a {@literal Set} containing all found aliases. Guaranteed to be not {@literal null}. */ - private static Set getFieldAliases(String query) { + static Set getFieldAliases(String query) { Set result = new HashSet<>(); Matcher matcher = FIELD_ALIAS_PATTERN.matcher(query); @@ -438,7 +418,14 @@ static Set getFunctionAliases(String query) { } private static String toJpaDirection(Order order) { - return order.getDirection().name().toLowerCase(Locale.US); + + String direction = order.getDirection().name().toLowerCase(Locale.US); + + return switch (order.getNullHandling()) { + case NATIVE -> direction; + case NULLS_FIRST -> direction + " nulls first"; + case NULLS_LAST -> direction + " nulls last"; + }; } /** @@ -446,11 +433,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)); @@ -532,8 +516,22 @@ private static Integer findClose(final Integer open, final List closes, * @param entityManager must not be {@literal null}. * @return Guaranteed to be not {@literal null}. */ - public static Query applyAndBind(String queryString, Iterable entities, EntityManager entityManager) { + return applyAndBind(queryString, entities, entityManager, PersistenceProvider.fromEntityManager(entityManager)); + } + + /** + * Creates a where-clause referencing the given entities and appends it to the given query string. Binds the given + * entities to the query. + * + * @param type of the entities. + * @param queryString must not be {@literal null}. + * @param entities must not be {@literal null}. + * @param entityManager must not be {@literal null}. + * @return Guaranteed to be not {@literal null}. + */ + static Query applyAndBind(String queryString, Iterable entities, EntityManager entityManager, + PersistenceProvider persistenceProvider) { Assert.notNull(queryString, "Querystring must not be null"); Assert.notNull(entities, "Iterable of entities must not be null"); @@ -545,6 +543,21 @@ public static Query applyAndBind(String queryString, Iterable entities, E return entityManager.createQuery(queryString); } + if (persistenceProvider == PersistenceProvider.HIBERNATE) { + + String alias = detectAlias(queryString); + Query query = entityManager.createQuery("%s where %s IN (?1)".formatted(queryString, alias)); + query.setParameter(1, entities instanceof Collection ? entities : Streamable.of(entities).toList()); + + return query; + } + + return applyWhereEqualsAndBind(queryString, entities, entityManager, iterator); + } + + private static Query applyWhereEqualsAndBind(String queryString, Iterable entities, EntityManager entityManager, + Iterator iterator) { + String alias = detectAlias(queryString); StringBuilder builder = new StringBuilder(queryString); builder.append(" where"); @@ -579,10 +592,8 @@ public static Query applyAndBind(String queryString, Iterable 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); } @@ -593,10 +604,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); } @@ -609,7 +618,8 @@ 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"); @@ -672,17 +682,6 @@ public static boolean hasNamedParameter(Query query) { return false; } - /** - * Returns whether the given query contains named parameters. - * - * @param query can be {@literal null} or empty. - * @return whether the given query contains named parameters. - */ - @Deprecated - static boolean hasNamedParameter(@Nullable String query) { - return StringUtils.hasText(query) && NAMED_PARAMETER.matcher(query).find(); - } - /** * Turns the given {@link Sort} into {@link jakarta.persistence.criteria.Order}s. * @@ -702,7 +701,7 @@ public static List toOrders(Sort sort, From< List orders = new ArrayList<>(); - for (org.springframework.data.domain.Sort.Order order : sort) { + for (Order order : sort) { orders.add(toJpaOrder(order, from, cb)); } @@ -750,254 +749,252 @@ 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 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 upper = cb.lower((Expression) 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); } } - static Expression toExpressionRecursively(From from, PropertyPath property) { - return toExpressionRecursively(from, property, false); - } + private static Nulls toNulls(Sort.NullHandling nullHandling) { - static Expression toExpressionRecursively(From from, PropertyPath property, boolean isForSelection) { - return toExpressionRecursively(from, property, isForSelection, false); + return switch (nullHandling) { + case NULLS_LAST -> Nulls.LAST; + case NULLS_FIRST -> Nulls.FIRST; + case NATIVE -> Nulls.NONE; + }; } /** - * Creates an expression with proper inner and left joins by recursively navigating the path + * Check any given {@link JpaOrder#isUnsafe()} order for presence of at least one property offending the + * {@link #PUNCTATION_PATTERN} and throw an {@link Exception} indicating potential unsafe order by expression. * - * @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? - * @param the type of the expression - * @return the expression + * @param order */ - @SuppressWarnings("unchecked") - static Expression toExpressionRecursively(From from, PropertyPath property, boolean isForSelection, - boolean hasRequiredOuterJoin) { - - String segment = property.getSegment(); - - boolean isLeafProperty = !property.hasNext(); - - boolean requiresOuterJoin = requiresOuterJoin(from, property, isForSelection, hasRequiredOuterJoin); + static void checkSortExpression(Order order) { - // if it does not require an outer join and is a leaf, simply get the segment - if (!requiresOuterJoin && isLeafProperty) { - return from.get(segment); + if (order instanceof JpaOrder jpaOrder && jpaOrder.isUnsafe()) { + return; } - // get or create the join - JoinType joinType = requiresOuterJoin ? JoinType.LEFT : JoinType.INNER; - Join join = getOrCreateJoin(from, segment, joinType); - - // if it's a leaf, return the join - if (isLeafProperty) { - return (Expression) join; + if (PUNCTATION_PATTERN.matcher(order.getProperty()).find()) { + throw new InvalidDataAccessApiUsageException(String.format(UNSAFE_PROPERTY_REFERENCE, order)); } + } - PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null"); + static Expression toExpressionRecursively(From from, PropertyPath property) { + return toExpressionRecursively(from, property, false); + } - // recurse with the next property - return toExpressionRecursively(join, nextProperty, isForSelection, requiresOuterJoin); + public static Expression toExpressionRecursively(From from, PropertyPath property, + boolean isForSelection) { + return FromExpressionFactory.INSTANCE.toExpressionRecursively(from, property, isForSelection, false); } /** - * 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). + * Expression factory to create {@link Expression}s from a CriteriaBuilder {@link From}. * - * @param from the {@link From} to check for fetches. - * @param property the property path - * @param isForSelection is the property navigated for the selection or ordering part of the query? if true, we need - * to generate an explicit outer join in order to prevent Hibernate to use an inner join instead. see - * https://hibernate.atlassian.net/browse/HHH-12999 - * @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. + * @since 4.0 */ - private static boolean requiresOuterJoin(From from, PropertyPath property, boolean isForSelection, - boolean hasRequiredOuterJoin) { - - // already inner joined so outer join is useless - if (isAlreadyInnerJoined(from, property.getSegment())) { - return false; - } - - Bindable model = from.getModel(); - ManagedType managedType = getManagedTypeForModel(model); - Bindable propertyPathModel = getModelForPath(property, managedType, from); + static class FromExpressionFactory extends ExpressionFactorySupport { + + private static final FromExpressionFactory INSTANCE = new FromExpressionFactory(); + + /** + * 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? + * @param the type of the expression + * @return the expression + */ + @SuppressWarnings({ "unchecked", "NullAway" }) + Expression toExpressionRecursively(From from, PropertyPath property, boolean isForSelection, + boolean hasRequiredOuterJoin) { + + String segment = property.getSegment(); + + boolean isLeafProperty = !property.hasNext(); + + FromPathResolver resolver = new FromPathResolver(from); + boolean isRelationshipId = isRelationshipId(resolver, property); + boolean requiresOuterJoin = requiresOuterJoin(resolver, property, isForSelection, hasRequiredOuterJoin, + isLeafProperty, isRelationshipId); + + // if it does not require an outer join and is a leaf or relationship id, simply get rest of the segment path + if (!requiresOuterJoin && (isLeafProperty || isRelationshipId)) { + Path trailingPath = from.get(segment); + while (property.hasNext()) { + property = property.next(); + trailingPath = trailingPath.get(property.getSegment()); + } + return trailingPath; + } - // is the attribute of Collection type? - boolean isPluralAttribute = model instanceof PluralAttribute; + // get or create the join + JoinType joinType = requiresOuterJoin ? JoinType.LEFT : JoinType.INNER; + Join join = getOrCreateJoin(from, segment, joinType); - if (propertyPathModel == null && isPluralAttribute) { - return true; - } + // if it's a leaf, return the join + if (isLeafProperty) { + return (Expression) join; + } - if (!(propertyPathModel instanceof Attribute attribute)) { - return false; - } + PropertyPath nextProperty = Objects.requireNonNull(property.next(), "An element of the property path is null"); + + // recurse with the next property + return toExpressionRecursively(join, 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 from the {@link From} to check for fetches. + * @param property the property path + * @param isForSelection is the property navigated for the selection or ordering part of the query? if true, we need + * to generate an explicit outer join in order to prevent Hibernate to use an inner join instead. see + * https://hibernate.atlassian.net/browse/HHH-12999 + * @param hasRequiredOuterJoin has a parent already required an outer join? + * @param isLeafProperty is leaf property + * @param isRelationshipId whether property path refers to relationship id + * @return whether an outer join is to be used for integrating this attribute in a query. + */ + private boolean requiresOuterJoin(FromPathResolver resolver, PropertyPath property, boolean isForSelection, + boolean hasRequiredOuterJoin, boolean isLeafProperty, boolean isRelationshipId) { + + // already inner joined so outer join is useless + if (isAlreadyInnerJoined(resolver.from(), property.getSegment())) { + return false; + } - // not a persistent attribute type association (@OneToOne, @ManyToOne) - if (!ASSOCIATION_TYPES.containsKey(attribute.getPersistentAttributeType())) { - return false; + return super.requiresOuterJoin(resolver, property, isForSelection, hasRequiredOuterJoin, isLeafProperty, + isRelationshipId); } - 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(getAnnotationProperty(attribute, "mappedBy", "")); + /** + * Returns an existing (fetch) join for the given attribute if one already exists or creates a new one if not. + * + * @param from the {@link From} to get the current joins from. + * @param attribute the {@link Attribute} to look for in the current joins. + * @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) { - boolean isLeafProperty = !property.hasNext(); - if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) { - return false; - } + for (Fetch fetch : from.getFetches()) { - return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true); - } - - @Nullable - private static T getAnnotationProperty(Attribute attribute, String propertyName, T defaultValue) { + if (fetch instanceof Join join && join.getAttribute().getName().equals(attribute)) { + return join; + } + } - Class associationAnnotation = ASSOCIATION_TYPES.get(attribute.getPersistentAttributeType()); + for (Join join : from.getJoins()) { - if (associationAnnotation == null) { - return defaultValue; + if (join.getAttribute().getName().equals(attribute)) { + return join; + } + } + return from.join(attribute, joinType); } - Member member = attribute.getJavaMember(); - - if (!(member instanceof AnnotatedElement annotatedMember)) { - return defaultValue; - } + /** + * Return whether the given {@link From} contains an inner join for the attribute with the given name. + * + * @param from the {@link From} to check for joins. + * @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) { - Annotation annotation = AnnotationUtils.getAnnotation(annotatedMember, associationAnnotation); - return annotation == null ? defaultValue : (T) AnnotationUtils.getValue(annotation, propertyName); - } + for (Fetch fetch : from.getFetches()) { - /** - * Returns an existing join for the given attribute if one already exists or creates a new one if not. - * - * @param from the {@link From} to get the current joins from. - * @param attribute the {@link Attribute} to look for in the current joins. - * @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) { + if (fetch.getAttribute().getName().equals(attribute) // + && fetch.getJoinType().equals(JoinType.INNER)) { + return true; + } + } - for (Join join : from.getJoins()) { + for (Join join : from.getJoins()) { - if (join.getAttribute().getName().equals(attribute)) { - return join; + if (join.getAttribute().getName().equals(attribute) // + && join.getJoinType().equals(JoinType.INNER)) { + return true; + } } + + return false; } - return from.join(attribute, joinType); - } - /** - * Return whether the given {@link From} contains an inner join for the attribute with the given name. - * - * @param from the {@link From} to check for joins. - * @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) { + record FromPathResolver(From from) implements ModelPathResolver { - for (Fetch fetch : from.getFetches()) { + @Override + public @Nullable Bindable resolve(PropertyPath propertyPath) { - if (fetch.getAttribute().getName().equals(attribute) // - && fetch.getJoinType().equals(JoinType.INNER)) { - return true; + Bindable model = from.getModel(); + ManagedType managedType = getManagedTypeForModel(model); + return getModelForPath(propertyPath, managedType, () -> from); } - } - for (Join join : from.getJoins()) { + @Override + @SuppressWarnings("NullAway") + public @Nullable Bindable resolveNext(PropertyPath propertyPath) { - if (join.getAttribute().getName().equals(attribute) // - && join.getJoinType().equals(JoinType.INNER)) { - return true; - } - } + Assert.state(propertyPath.hasNext(), "PropertyPath must contain at least one element"); - return false; - } + Bindable propertyPathModel = resolve(propertyPath); - /** - * Check any given {@link JpaOrder#isUnsafe()} order for presence of at least one property offending the - * {@link #PUNCTATION_PATTERN} and throw an {@link Exception} indicating potential unsafe order by expression. - * - * @param order - */ - static void checkSortExpression(Order order) { - - if (order instanceof JpaOrder jpaOrder && jpaOrder.isUnsafe()) { - return; - } + ManagedType propertyPathManagedType = getManagedTypeForModel(propertyPathModel); + return getModelForPath(propertyPath.next(), propertyPathManagedType, + () -> from.get(propertyPath.next().getSegment())); + } - if (PUNCTATION_PATTERN.matcher(order.getProperty()).find()) { - throw new InvalidDataAccessApiUsageException(String.format(UNSAFE_PROPERTY_REFERENCE, order)); - } - } + /** + * Get the {@link Bindable model} that corresponds to the given path utilizing the given {@link ManagedType} if + * present or resolving the model from the {@link Path#getModel() path} by creating it via + * {@link From#get(String)} in case where the type signature may be erased by some vendors if the attribute + * contains generics. + * + * @param path the current {@link PropertyPath} segment. + * @param managedType primary source for the resulting {@link Bindable}. Can be {@literal null}. + * @param fallback must not be {@literal null}. + * @return the corresponding {@link Bindable}. + * @see https://hibernate.atlassian.net/browse/HHH-16144 + * @see https://github.com/jakartaee/persistence/issues/562 + */ + private static Bindable getModelForPath(PropertyPath path, @Nullable ManagedType managedType, + Supplier> fallback) { + + String segment = path.getSegment(); + if (managedType != null) { + try { + return (Bindable) managedType.getAttribute(segment); + } catch (IllegalArgumentException ex) { + // ManagedType may be erased for some vendor if the attribute is declared as generic + } + } - /** - * Get the {@link Bindable model} that corresponds to the given path utilizing the given {@link ManagedType} if - * present or resolving the model from the {@link Path#getModel() path} by creating it via {@link From#get(String)} in - * case where the type signature may be erased by some vendors if the attribute contains generics. - * - * @param path the current {@link PropertyPath} segment. - * @param managedType primary source for the resulting {@link Bindable}. Can be {@literal null}. - * @param fallback must not be {@literal null}. - * @return the corresponding {@link Bindable} of {@literal null}. - * @see https://hibernate.atlassian.net/browse/HHH-16144 - * @see https://github.com/jakartaee/persistence/issues/562 - */ - @Nullable - private static Bindable getModelForPath(PropertyPath path, @Nullable ManagedType managedType, - Path fallback) { - - String segment = path.getSegment(); - if (managedType != null) { - try { - return (Bindable) managedType.getAttribute(segment); - } catch (IllegalArgumentException ex) { - // ManagedType may be erased for some vendor if the attribute is declared as generic + return (Bindable) fallback.get().get(segment); } } - - return fallback.get(segment).getModel(); } - /** - * Required for EclipseLink: we try to avoid using from.get as EclipseLink produces an inner join regardless of which - * join operation is specified next - * - * @see https://bugs.eclipse.org/bugs/show_bug.cgi?id=413892 - * @param model - * @return - */ - @Nullable - private static ManagedType getManagedTypeForModel(Bindable model) { - - if (model instanceof ManagedType managedType) { - return managedType; - } - - if (!(model instanceof SingularAttribute singularAttribute)) { - return null; - } - - return singularAttribute.getType() instanceof ManagedType managedType ? managedType : null; - } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ScrollDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ScrollDelegate.java index 359dfb6ea2..7fbe146bb4 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ScrollDelegate.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ScrollDelegate.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/SimilarityNormalizer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/SimilarityNormalizer.java new file mode 100644 index 0000000000..daead89db0 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/SimilarityNormalizer.java @@ -0,0 +1,125 @@ +/* + * 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.HashMap; +import java.util.Map; +import java.util.function.DoubleUnaryOperator; + +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.VectorScoringFunctions; + +/** + * Normalizes the score returned by a database to a similarity value and vice versa. + * + * @author Mark Paluch + * @since 4.0 + * @see org.springframework.data.domain.Similarity + */ +public class SimilarityNormalizer { + + /** + * Identity normalizer for {@link ScoringFunction#unspecified()} scoring function without altering the score. + */ + public static final SimilarityNormalizer IDENTITY = new SimilarityNormalizer(ScoringFunction.unspecified(), + DoubleUnaryOperator.identity(), DoubleUnaryOperator.identity()); + + /** + * Normalizer for Euclidean scores using {@code euclidean_distance(…)} as the scoring function. + */ + public static final SimilarityNormalizer EUCLIDEAN = new SimilarityNormalizer(VectorScoringFunctions.EUCLIDEAN, + it -> 1 / (1.0 + Math.pow(it, 2)), it -> it == 0 ? Float.MAX_VALUE : Math.sqrt((1 / it) - 1)); + + /** + * Normalizer for Cosine scores using {@code cosine_distance(…)} as the scoring function. + */ + public static final SimilarityNormalizer COSINE = new SimilarityNormalizer(VectorScoringFunctions.COSINE, + it -> (1.0 + (1 - it)) / 2.0, it -> 1 - ((it * 2) - 1)); + + /** + * Normalizer for Negative Inner Product (Dot) scores using {@code negative_inner_product(…)} as the scoring function. + */ + public static final SimilarityNormalizer DOT_PRODUCT = new SimilarityNormalizer(VectorScoringFunctions.DOT_PRODUCT, + it -> (1 - it) / 2, it -> 1 - (it * 2)); + + private static final Map NORMALIZERS = new HashMap<>(); + + static { + NORMALIZERS.put(EUCLIDEAN.scoringFunction, EUCLIDEAN); + NORMALIZERS.put(COSINE.scoringFunction, COSINE); + NORMALIZERS.put(DOT_PRODUCT.scoringFunction, DOT_PRODUCT); + } + + private final ScoringFunction scoringFunction; + private final DoubleUnaryOperator similarity; + private final DoubleUnaryOperator score; + + /** + * Constructor for {@link SimilarityNormalizer} using the given {@link DoubleUnaryOperator} for similarity and score + * computation. + * + * @param similarity compute the similarity from the underlying score returned by a database result. + * @param score compute the score value from a given {@link org.springframework.data.domain.Similarity} to compare + * against database results. + */ + SimilarityNormalizer(ScoringFunction scoringFunction, DoubleUnaryOperator similarity, DoubleUnaryOperator score) { + this.scoringFunction = scoringFunction; + this.score = score; + this.similarity = similarity; + } + + /** + * Lookup a {@link SimilarityNormalizer} for a given {@link ScoringFunction}. + * + * @param scoringFunction the scoring function to translate. + * @return the {@link SimilarityNormalizer} for the given {@link ScoringFunction}. + * @throws IllegalArgumentException if the {@link ScoringFunction} is not associated with a + * {@link SimilarityNormalizer}. + */ + public static SimilarityNormalizer get(ScoringFunction scoringFunction) { + + SimilarityNormalizer normalizer = NORMALIZERS.get(scoringFunction); + + if (normalizer == null) { + throw new IllegalArgumentException("No SimilarityNormalizer found for " + scoringFunction.getName()); + } + + return normalizer; + } + + /** + * @param score score value as returned by the database. + * @return the {@link org.springframework.data.domain.Similarity} value. + */ + public double getSimilarity(double score) { + return similarity.applyAsDouble(score); + } + + /** + * @param similarity similarity value as requested by the query mechanism. + * @return database score value. + */ + public double getScore(double similarity) { + return score.applyAsDouble(similarity); + } + + @Override + public String toString() { + return "%s Normalizer: Similarity[0 to 1] -> Score[%f to %f]".formatted(scoringFunction.getName(), getScore(0), + getScore(1)); + } + +} 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 5174168501..4d1f5e7f44 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -18,11 +18,10 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.Query; -import org.springframework.data.jpa.repository.QueryRewriter; -import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.jspecify.annotations.Nullable; + +import org.springframework.data.repository.query.QueryCreationException; import org.springframework.data.repository.query.RepositoryQuery; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.lang.Nullable; /** * {@link RepositoryQuery} implementation that inspects a {@link org.springframework.data.repository.query.QueryMethod} @@ -34,44 +33,26 @@ * @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 evaluationContextProvider must not be {@literal null} - * @param parser must not be {@literal null} - */ - public SimpleJpaQuery(JpaQueryMethod method, EntityManager em, @Nullable String countQueryString, - QueryRewriter queryRewriter, QueryMethodEvaluationContextProvider evaluationContextProvider, SpelExpressionParser parser) { - this(method, em, method.getRequiredAnnotatedQuery(), countQueryString, queryRewriter, evaluationContextProvider, parser); - } +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 evaluationContextProvider must not be {@literal null} - * @param parser 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, - QueryMethodEvaluationContextProvider evaluationContextProvider, SpelExpressionParser parser) { + public SimpleJpaQuery(JpaQueryMethod method, EntityManager em, DeclaredQuery query, + @Nullable DeclaredQuery countQuery, JpaQueryConfiguration queryConfiguration) { - super(method, em, queryString, countQueryString, queryRewriter, evaluationContextProvider, parser); + super(method, em, query, countQuery, queryConfiguration); - validateQuery(getQuery().getQueryString(), "Validation failed for query for method %s", method); + validateQuery(getQuery(), "Query validation failed for '%s'", method); if (method.isPageQuery()) { - validateQuery(getCountQuery().getQueryString(), - String.format("Count query validation failed for method %s", method)); + validateQuery(getCountQuery(), "Count query validation failed for '%s'", method); } } @@ -81,29 +62,20 @@ public SimpleJpaQuery(JpaQueryMethod method, EntityManager em, String queryStrin * @param query * @param errorMessage */ - private void validateQuery(String query, String errorMessage, Object... arguments) { + private void validateQuery(QueryProvider query, String errorMessage, JpaQueryMethod method) { if (getQueryMethod().isProcedureQuery()) { return; } - EntityManager validatingEm = null; - - try { - validatingEm = getEntityManager().getEntityManagerFactory().createEntityManager(); - validatingEm.createQuery(query); - + String queryString = query.getQueryString(); + try (EntityManager validatingEm = getEntityManager().getEntityManagerFactory().createEntityManager()) { + validatingEm.createQuery(queryString); } 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(); - } + // https://download.oracle.com/javaee-archive/jpa-spec.java.net/users/2012/07/0404.html + throw QueryCreationException.create(method, errorMessage.formatted(queryString), 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 770c946f64..bb9a8ba69c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -26,8 +26,9 @@ import java.util.Collections; import java.util.List; +import org.jspecify.annotations.Nullable; + import org.springframework.core.annotation.AnnotatedElementUtils; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -174,8 +175,7 @@ private List 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"); @@ -213,7 +213,7 @@ private String derivedNamedProcedureNameFrom(Method method, JpaEntityMetadata return StringUtils.hasText(procedure.name()) // ? procedure.name() // - : entityMetadata.getEntityName() + "." + method.getName(); + : entityMetadata.getJavaType().getSimpleName() + "." + method.getName(); } /** 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 a489b490c2..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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -15,14 +15,16 @@ */ package org.springframework.data.jpa.repository.query; +import jakarta.persistence.StoredProcedureQuery; + import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import java.util.stream.IntStream; -import jakarta.persistence.StoredProcedureQuery; - +import org.jspecify.annotations.Nullable; import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; /** @@ -92,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 // @@ -138,7 +140,12 @@ public boolean hasReturnValue() { if (getOutputProcedureParameters().isEmpty()) return false; - Class outputType = getOutputProcedureParameters().get(0).getType(); - return !(void.class.equals(outputType) || Void.class.equals(outputType)); + for (ProcedureParameter parameter : getOutputProcedureParameters()) { + if (!ClassUtils.isVoidType(parameter.getType())) { + return true; + } + } + + return false; } } 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 e9353b83ed..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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -15,20 +15,21 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - import jakarta.persistence.EntityManager; import jakarta.persistence.NamedStoredProcedureQuery; import jakarta.persistence.ParameterMode; import jakarta.persistence.StoredProcedureQuery; import jakarta.persistence.TypedQuery; +import java.util.HashMap; +import java.util.List; +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 @@ -138,11 +141,17 @@ Object extractOutputValue(StoredProcedureQuery storedProcedureQuery) { * @return The value of an output parameter either by name or by index. */ private Object extractOutputParameterValue(ProcedureParameter outputParameter, - StoredProcedureQuery storedProcedureQuery) { + StoredProcedureQuery query) { + + if (procedureAttributes.isNamedStoredProcedure() && StringUtils.hasText(outputParameter.getName())) { + + return StringUtils.hasText(outputParameter.getName()) ? query.getOutputParameterValue(outputParameter.getName()) + : query.getOutputParameterValue(outputParameter.getPosition()); + } return useNamedParameters && StringUtils.hasText(outputParameter.getName()) - ? storedProcedureQuery.getOutputParameterValue(outputParameter.getName()) - : storedProcedureQuery.getOutputParameterValue(outputParameter.getPosition()); + ? query.getOutputParameterValue(outputParameter.getName()) + : query.getOutputParameterValue(outputParameter.getPosition()); } /** 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/StringQuery.java deleted file mode 100644 index 5bea73422c..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java +++ /dev/null @@ -1,547 +0,0 @@ -/* - * Copyright 2013-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 static java.util.regex.Pattern.*; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.function.BiFunction; -import java.util.function.Consumer; -import java.util.function.Function; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -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.SpelQueryContext; -import org.springframework.data.repository.query.SpelQueryContext.SpelExtractor; -import org.springframework.data.repository.query.parser.Part.Type; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.util.ObjectUtils; -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. - * - * @author Oliver Gierke - * @author Thomas Darimont - * @author Oliver Wehrens - * @author Mark Paluch - * @author Jens Schauder - * @author Diego Krupitza - * @author Greg Turnquist - * @author Yuriy Tsarkov - */ -class StringQuery implements DeclaredQuery { - - private final String query; - private final List bindings; - private final @Nullable String alias; - private final boolean hasConstructorExpression; - private final boolean containsPageableInSpel; - private final boolean usesJdbcStyleParameters; - private final boolean isNative; - private final QueryEnhancer queryEnhancer; - - /** - * Creates a new {@link StringQuery} from the given JPQL query. - * - * @param query must not be {@literal null} or empty. - */ - StringQuery(String query, boolean isNative) { - - 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); - - this.usesJdbcStyleParameters = queryMeta.usesJdbcStyleParameters; - - this.queryEnhancer = QueryEnhancerFactory.forQuery(this); - this.alias = this.queryEnhancer.detectAlias(); - this.hasConstructorExpression = this.queryEnhancer.hasConstructorExpression(); - } - - /** - * Returns whether we have found some like bindings. - */ - boolean hasParameterBindings() { - return !bindings.isEmpty(); - } - - String getProjection() { - return this.queryEnhancer.getProjection(); - } - - @Override - public List getParameterBindings() { - return bindings; - } - - @Override - public DeclaredQuery deriveCountQuery(@Nullable String countQueryProjection) { - - StringQuery stringQuery = new StringQuery(this.queryEnhancer.createCountQueryFor(countQueryProjection), // - this.isNative); - - if (this.hasParameterBindings() && !this.getParameterBindings().equals(stringQuery.getParameterBindings())) { - stringQuery.getParameterBindings().clear(); - stringQuery.getParameterBindings().addAll(this.bindings); - } - - return stringQuery; - } - - @Override - public boolean usesJdbcStyleParameters() { - return usesJdbcStyleParameters; - } - - @Override - public String getQueryString() { - return query; - } - - @Override - @Nullable - public String getAlias() { - return alias; - } - - @Override - public boolean hasConstructorExpression() { - return hasConstructorExpression; - } - - @Override - public boolean isDefaultProjection() { - return getProjection().equalsIgnoreCase(alias); - } - - @Override - public boolean hasNamedParameter() { - return bindings.stream().anyMatch(b -> b.getIdentifier().hasName()); - } - - @Override - public boolean usesPaging() { - return containsPageableInSpel; - } - - @Override - public boolean isNativeQuery() { - return isNative; - } - - /** - * A parser that extracts the parameter bindings from a given query string. - * - * @author Thomas Darimont - */ - enum ParameterBindingParser { - - INSTANCE; - - private static final String EXPRESSION_PARAMETER_PREFIX = "__$synthetic$__"; - public static final String POSITIONAL_OR_INDEXED_PARAMETER = "\\?(\\d*+(?![#\\w]))"; - // .....................................................................^ not followed by a hash or a letter. - // .................................................................^ zero or more digits. - // .............................................................^ start with a question mark. - private static final Pattern PARAMETER_BINDING_BY_INDEX = Pattern.compile(POSITIONAL_OR_INDEXED_PARAMETER); - private static final Pattern PARAMETER_BINDING_PATTERN; - private static final Pattern JDBC_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?(?!\\d)"); // no \ and [no digit] - private static final Pattern NUMBERED_STYLE_PARAM = Pattern.compile("(?!\\\\)\\?\\d"); // no \ and [digit] - 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"; - private static final int INDEXED_PARAMETER_GROUP = 4; - private static final int NAMED_PARAMETER_GROUP = 6; - private static final int COMPARISION_TYPE_GROUP = 1; - - static { - - List keywords = new ArrayList<>(); - - for (ParameterBindingType type : ParameterBindingType.values()) { - if (type.getKeyword() != null) { - keywords.add(type.getKeyword()); - } - } - - StringBuilder builder = new StringBuilder(); - builder.append("("); - builder.append(StringUtils.collectionToDelimitedString(keywords, "|")); // keywords - builder.append(")?"); - builder.append("(?: )?"); // some whitespace - builder.append("\\(?"); // optional braces around parameters - builder.append("("); - builder.append("%?(" + POSITIONAL_OR_INDEXED_PARAMETER + ")%?"); // position parameter and parameter index - builder.append("|"); // or - - // named parameter and the parameter name - builder.append("%?(" + QueryUtils.COLON_NO_DOUBLE_COLON + QueryUtils.IDENTIFIER_GROUP + ")%?"); - - builder.append(")"); - builder.append("\\)?"); // optional braces around parameters - - PARAMETER_BINDING_PATTERN = Pattern.compile(builder.toString(), CASE_INSENSITIVE); - } - - /** - * Parses {@link ParameterBinding} instances from the given query and adds them to the registered bindings. Returns - * the cleaned up query. - */ - private String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String query, - List bindings, Metadata queryMeta) { - - int greatestParameterIndex = tryFindGreatestParameterIndexIn(query); - boolean parametersShouldBeAccessedByIndex = greatestParameterIndex != -1; - - /* - * Prefer indexed access over named parameters if only SpEL Expression parameters are present. - */ - if (!parametersShouldBeAccessedByIndex && query.contains("?#{")) { - parametersShouldBeAccessedByIndex = true; - greatestParameterIndex = 0; - } - - SpelExtractor spelExtractor = createSpelExtractor(query, parametersShouldBeAccessedByIndex, - greatestParameterIndex); - - String resultingQuery = spelExtractor.getQueryString(); - Matcher matcher = PARAMETER_BINDING_PATTERN.matcher(resultingQuery); - - int expressionParameterIndex = parametersShouldBeAccessedByIndex ? greatestParameterIndex : 0; - int syntheticParameterIndex = expressionParameterIndex + spelExtractor.size(); - - ParameterBindings parameterBindings = new ParameterBindings(bindings, it -> checkAndRegister(it, bindings), - syntheticParameterIndex); - int currentIndex = 0; - - boolean usesJpaStyleParameters = false; - - while (matcher.find()) { - - if (spelExtractor.isQuoted(matcher.start())) { - continue; - } - - String parameterIndexString = matcher.group(INDEXED_PARAMETER_GROUP); - String parameterName = parameterIndexString != null ? null : matcher.group(NAMED_PARAMETER_GROUP); - Integer parameterIndex = getParameterIndex(parameterIndexString); - - String match = matcher.group(0); - if (JDBC_STYLE_PARAM.matcher(match).find()) { - queryMeta.usesJdbcStyleParameters = true; - } - - if (NUMBERED_STYLE_PARAM.matcher(match).find() || NAMED_STYLE_PARAM.matcher(match).find()) { - usesJpaStyleParameters = true; - } - - if (usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) { - throw new IllegalArgumentException("Mixing of ? parameters and other forms like ?1 is not supported"); - } - - String typeSource = matcher.group(COMPARISION_TYPE_GROUP); - Assert.isTrue(parameterIndexString != null || parameterName != null, - () -> String.format("We need either a name or an index; Offending query string: %s", query)); - String expression = spelExtractor.getParameter(parameterName == null ? parameterIndexString : parameterName); - String replacement = null; - - expressionParameterIndex++; - if ("".equals(parameterIndexString)) { - parameterIndex = expressionParameterIndex; - } - - BindingIdentifier queryParameter; - if (parameterIndex != null) { - queryParameter = BindingIdentifier.of(parameterIndex); - } else { - queryParameter = BindingIdentifier.of(parameterName); - } - ParameterOrigin origin = ObjectUtils.isEmpty(expression) - ? ParameterOrigin.ofParameter(parameterName, parameterIndex) - : ParameterOrigin.ofExpression(expression); - - BindingIdentifier targetBinding = queryParameter; - Function bindingFactory; - switch (ParameterBindingType.of(typeSource)) { - - case LIKE: - - Type likeType = LikeParameterBinding.getLikeTypeFrom(matcher.group(2)); - bindingFactory = (identifier) -> new LikeParameterBinding(identifier, origin, likeType); - break; - - case IN: - bindingFactory = (identifier) -> new InParameterBinding(identifier, origin); - break; - - case AS_IS: // fall-through we don't need a special parameter queryParameter for the given parameter. - default: - bindingFactory = (identifier) -> new ParameterBinding(identifier, origin); - } - - if (origin.isExpression()) { - parameterBindings.register(bindingFactory.apply(queryParameter)); - } else { - targetBinding = parameterBindings.register(queryParameter, origin, bindingFactory); - } - - replacement = targetBinding.hasName() ? ":" + targetBinding.getName() - : ((!usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) ? "?" - : "?" + targetBinding.getPosition()); - String result; - String substring = matcher.group(2); - - int index = resultingQuery.indexOf(substring, currentIndex); - if (index < 0) { - result = resultingQuery; - } else { - currentIndex = index + replacement.length(); - result = resultingQuery.substring(0, index) + replacement - + resultingQuery.substring(index + substring.length()); - } - - resultingQuery = result; - } - - return resultingQuery; - } - - private static SpelExtractor createSpelExtractor(String queryWithSpel, boolean parametersShouldBeAccessedByIndex, - int greatestParameterIndex) { - - /* - * If parameters need to be bound by index, we bind the synthetic expression parameters starting from position of the greatest discovered index parameter in order to - * not mix-up with the actual parameter indices. - */ - int expressionParameterIndex = parametersShouldBeAccessedByIndex ? greatestParameterIndex : 0; - - BiFunction indexToParameterName = parametersShouldBeAccessedByIndex - ? (index, expression) -> String.valueOf(index + expressionParameterIndex + 1) - : (index, expression) -> EXPRESSION_PARAMETER_PREFIX + (index + 1); - - String fixedPrefix = parametersShouldBeAccessedByIndex ? "?" : ":"; - - BiFunction parameterNameToReplacement = (prefix, name) -> fixedPrefix + name; - - return SpelQueryContext.of(indexToParameterName, parameterNameToReplacement).parse(queryWithSpel); - } - - @Nullable - private static Integer getParameterIndex(@Nullable String parameterIndexString) { - - if (parameterIndexString == null || parameterIndexString.isEmpty()) { - return null; - } - return Integer.valueOf(parameterIndexString); - } - - private static int tryFindGreatestParameterIndexIn(String query) { - - Matcher parameterIndexMatcher = PARAMETER_BINDING_BY_INDEX.matcher(query); - - int greatestParameterIndex = -1; - while (parameterIndexMatcher.find()) { - - String parameterIndexString = parameterIndexMatcher.group(1); - Integer parameterIndex = getParameterIndex(parameterIndexString); - if (parameterIndex != null) { - greatestParameterIndex = Math.max(greatestParameterIndex, parameterIndex); - } - } - - return greatestParameterIndex; - } - - private static void checkAndRegister(ParameterBinding binding, List bindings) { - - bindings.stream() // - .filter(it -> it.bindsTo(binding)) // - .forEach(it -> Assert.isTrue(it.equals(binding), String.format(MESSAGE, it, binding))); - - if (!bindings.contains(binding)) { - bindings.add(binding); - } - } - - /** - * An enum for the different types of bindings. - * - * @author Thomas Darimont - * @author Oliver Gierke - */ - private enum ParameterBindingType { - - // Trailing whitespace is intentional to reflect that the keywords must be used with at least one whitespace - // character, while = does not. - LIKE("like "), IN("in "), AS_IS(null); - - private final @Nullable String keyword; - - ParameterBindingType(@Nullable String keyword) { - this.keyword = keyword; - } - - /** - * Returns the keyword that will trigger the binding type or {@literal null} if the type is not triggered by a - * keyword. - * - * @return the keyword - */ - @Nullable - public String getKeyword() { - return keyword; - } - - /** - * Return the appropriate {@link ParameterBindingType} for the given {@link String}. Returns {@literal #AS_IS} in - * case no other {@link ParameterBindingType} could be found. - */ - static ParameterBindingType of(String typeSource) { - - if (!StringUtils.hasText(typeSource)) { - return AS_IS; - } - - for (ParameterBindingType type : values()) { - if (type.name().equalsIgnoreCase(typeSource.trim())) { - return type; - } - } - - throw new IllegalArgumentException(String.format("Unsupported parameter binding type %s", typeSource)); - } - } - } - - private 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}. - * - * @author Mark Paluch - * @since 3.1.2 - */ - static class ParameterBindings { - - private final MultiValueMap methodArgumentToLikeBindings = new LinkedMultiValueMap<>(); - - private final Consumer registration; - private int syntheticParameterIndex; - - public ParameterBindings(List bindings, Consumer registration, - int syntheticParameterIndex) { - - for (ParameterBinding binding : bindings) { - this.methodArgumentToLikeBindings.put(binding.getIdentifier(), new ArrayList<>(List.of(binding))); - } - - this.registration = registration; - this.syntheticParameterIndex = syntheticParameterIndex; - } - - /** - * Return whether the identifier is already bound. - * - * @param identifier - * @return - */ - public boolean isBound(BindingIdentifier identifier) { - return !getBindings(identifier).isEmpty(); - } - - BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin, - Function bindingFactory) { - - Assert.isInstanceOf(MethodInvocationArgument.class, origin); - - BindingIdentifier methodArgument = ((MethodInvocationArgument) origin).identifier(); - List bindingsForOrigin = getBindings(methodArgument); - - if (!isBound(identifier)) { - - ParameterBinding binding = bindingFactory.apply(identifier); - registration.accept(binding); - bindingsForOrigin.add(binding); - return binding.getIdentifier(); - } - - ParameterBinding binding = bindingFactory.apply(identifier); - - for (ParameterBinding existing : bindingsForOrigin) { - - if (existing.isCompatibleWith(binding)) { - return existing.getIdentifier(); - } - } - - BindingIdentifier syntheticIdentifier; - if (identifier.hasName() && methodArgument.hasName()) { - - int index = 0; - String newName = methodArgument.getName(); - while (existsBoundParameter(newName)) { - index++; - newName = methodArgument.getName() + "_" + index; - } - syntheticIdentifier = BindingIdentifier.of(newName); - } else { - syntheticIdentifier = BindingIdentifier.of(++syntheticParameterIndex); - } - - ParameterBinding newBinding = bindingFactory.apply(syntheticIdentifier); - registration.accept(newBinding); - bindingsForOrigin.add(newBinding); - return newBinding.getIdentifier(); - } - - private boolean existsBoundParameter(String key) { - return methodArgumentToLikeBindings.values().stream().flatMap(Collection::stream) - .anyMatch(it -> key.equals(it.getName())); - } - - private List getBindings(BindingIdentifier identifier) { - return methodArgumentToLikeBindings.computeIfAbsent(identifier, s -> new ArrayList<>()); - } - - public void register(ParameterBinding parameterBinding) { - registration.accept(parameterBinding); - } - } -} 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 51% 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 411c2662d5..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -15,19 +15,21 @@ */ package org.springframework.data.jpa.repository.query; +import java.util.Objects; import java.util.regex.Pattern; -import org.springframework.data.repository.core.EntityMetadata; -import org.springframework.expression.Expression; -import org.springframework.expression.ParserContext; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.expression.spel.support.StandardEvaluationContext; +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.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. *

- * Currently the following template variables are available: + * Currently, the following template variables are available: *

    *
  1. {@code #entityName} - the simple class name of the given entity
  2. *
      @@ -39,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__{"; @@ -51,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, SpelExpressionParser 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, - SpelExpressionParser 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()); } /** @@ -83,8 +96,8 @@ 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, - SpelExpressionParser parser) { + static String renderQueryIfExpressionOrReturnQuery(String query, JpaEntityMetadata metadata, + ValueExpressionParser parser) { Assert.notNull(query, "query must not be null"); Assert.notNull(metadata, "metadata must not be null"); @@ -94,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); - Expression expr = parser.parseExpression(query, ParserContext.TEMPLATE_EXPRESSION); + ValueExpression expr = parser.parse(query); - String result = expr.getValue(evalContext, String.class); + String result = Objects.toString(expr.evaluate(ValueEvaluationContext.of(DEFAULT_ENVIRONMENT, evalContext))); if (result == null) { return query; @@ -121,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 ae738974b1..34d05a6043 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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -18,10 +18,10 @@ import jakarta.persistence.LockModeType; import java.lang.reflect.Method; -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 +76,8 @@ public interface CrudMethodMetadata { * @return * @since 1.9 */ - Optional 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 246e82dcd6..9f22d22546 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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -20,7 +20,6 @@ import java.lang.reflect.Method; import java.util.HashSet; -import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -28,6 +27,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 +41,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 +60,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 +120,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 +185,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; + private final @Nullable EntityGraph entityGraph; private final Method method; /** @@ -204,12 +205,11 @@ private static class DefaultCrudMethodMetadata implements CrudMethodMetadata { this.method = method; } - private static Optional 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 +238,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 +260,12 @@ public org.springframework.data.jpa.repository.support.QueryHints getQueryHintsF } @Override - public String getComment() { + public @Nullable String getComment() { return comment; } @Override - public Optional getEntityGraph() { + public @Nullable EntityGraph getEntityGraph() { return entityGraph; } @@ -291,7 +289,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/DefaultJpaContext.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/DefaultJpaContext.java index e6e51b991d..464fd122ff 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/DefaultJpaContext.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/DefaultJpaContext.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. @@ -15,12 +15,14 @@ */ package org.springframework.data.jpa.repository.support; -import java.util.List; -import java.util.Set; - import jakarta.persistence.EntityManager; import jakarta.persistence.metamodel.ManagedType; +import java.util.List; +import java.util.Set; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.jpa.repository.JpaContext; import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; @@ -37,6 +39,25 @@ public class DefaultJpaContext implements JpaContext { private final MultiValueMap, EntityManager> entityManagers; + /** + * Creates a new {@link DefaultJpaContext} for the given {@link Set} of {@link EntityManager}s. + * + * @param entityManagers must not be {@literal null}. + */ + @Autowired + public DefaultJpaContext(ObjectProvider entityManagers) { + + Assert.notNull(entityManagers, "EntityManagerFactories must not be null"); + + this.entityManagers = new LinkedMultiValueMap<>(); + + for (EntityManager em : entityManagers) { + for (ManagedType managedType : em.getMetamodel().getManagedTypes()) { + this.entityManagers.add(managedType.getJavaType(), em); + } + } + } + /** * Creates a new {@link DefaultJpaContext} for the given {@link Set} of {@link EntityManager}s. * 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 bbf5b12a9a..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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -15,15 +15,14 @@ */ package org.springframework.data.jpa.repository.support; -import java.util.Optional; -import java.util.function.BiConsumer; - import jakarta.persistence.EntityManager; +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; + 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, 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())) - .orElse(new MutableQueryHints()); + 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 97bf993818..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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. @@ -61,10 +61,15 @@ public static EntityGraph create(EntityManager entityManager, Class do currentFullPath += path.getSegment() + "."; if (path.hasNext()) { - final Subgraph 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 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/EntityManagerBeanDefinitionRegistrarPostProcessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessor.java index d48c84af96..61096544d8 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessor.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -20,6 +20,8 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; +import java.util.function.BiPredicate; + import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.annotation.Qualifier; @@ -44,9 +46,31 @@ * @author Réda Housni Alaoui * @author Mark Paluch * @author Donghun Shin + * @deprecated since 4.0, in favor of using either {@link org.springframework.orm.jpa.AbstractEntityManagerFactoryBean} + * that provides a shared {@link EntityManager} or using {@link SharedEntityManagerCreator} directly in your + * configuration. */ +@Deprecated(since = "4.0") public class EntityManagerBeanDefinitionRegistrarPostProcessor implements BeanFactoryPostProcessor, Ordered { + private final BiPredicate decoratorPredicate; + + public EntityManagerBeanDefinitionRegistrarPostProcessor() { + this((beanName, beanDefinition) -> true); + } + + /** + * Creates a new {@code EntityManagerBeanDefinitionRegistrarPostProcessor} allowing to filter which + * {@link EntityManagerFactory} beans should be decorated with a {@code SharedEntityManagerCreator}. + * + * @param decoratorPredicate the predicate to determine whether a given named {@link BeanDefinition} should be + * decorated with a {@code SharedEntityManagerCreator}. + * @since 4.0 + */ + public EntityManagerBeanDefinitionRegistrarPostProcessor(BiPredicate decoratorPredicate) { + this.decoratorPredicate = decoratorPredicate; + } + @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE + 10; @@ -55,13 +79,8 @@ public int getOrder() { @Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { - if (!ConfigurableListableBeanFactory.class.isInstance(beanFactory)) { - return; - } - - ConfigurableListableBeanFactory factory = beanFactory; - - for (EntityManagerFactoryBeanDefinition definition : getEntityManagerFactoryBeanDefinitions(factory)) { + for (EntityManagerFactoryBeanDefinition definition : getEntityManagerFactoryBeanDefinitions(beanFactory, + decoratorPredicate)) { BeanFactory definitionFactory = definition.getBeanFactory(); 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 9ed0a0ce3e..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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. @@ -16,7 +16,6 @@ package org.springframework.data.jpa.repository.support; import jakarta.persistence.EntityManager; -import jakarta.persistence.Query; import java.util.ArrayList; import java.util.Collection; @@ -27,20 +26,32 @@ import java.util.stream.Stream; import org.springframework.dao.IncorrectResultSizeDataAccessException; +import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Window; +import org.springframework.data.jpa.repository.query.KeysetScrollDelegate; import org.springframework.data.jpa.repository.query.ScrollDelegate; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.util.Assert; +import com.querydsl.core.types.EntityPath; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.ExpressionBase; import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.Visitor; +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 @@ -57,33 +68,40 @@ */ class FetchableFluentQueryByPredicate extends FluentQuerySupport implements FetchableFluentQuery { + private final EntityPath entityPath; + private final JpaEntityInformation entityInformation; + private final ScrollQueryFactory> scrollQueryFactory; private final Predicate predicate; private final Function> finder; - private final PredicateScrollDelegate scroll; private final BiFunction> pagedFinder; private final Function countOperation; private final Function existsOperation; private final EntityManager entityManager; - FetchableFluentQueryByPredicate(Predicate predicate, Class entityType, - Function> finder, PredicateScrollDelegate scroll, + FetchableFluentQueryByPredicate(EntityPath entityPath, Predicate predicate, + JpaEntityInformation entityInformation, Function> finder, + ScrollQueryFactory> scrollQueryFactory, BiFunction> pagedFinder, Function countOperation, Function existsOperation, EntityManager entityManager, ProjectionFactory projectionFactory) { - this(predicate, entityType, (Class) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scroll, - pagedFinder, countOperation, existsOperation, entityManager, projectionFactory); + this(entityPath, predicate, entityInformation, (Class) entityInformation.getJavaType(), Sort.unsorted(), 0, + Collections.emptySet(), finder, scrollQueryFactory, pagedFinder, countOperation, existsOperation, entityManager, + projectionFactory); } - private FetchableFluentQueryByPredicate(Predicate predicate, Class entityType, Class resultType, Sort sort, - int limit, Collection properties, Function> finder, - PredicateScrollDelegate scroll, BiFunction> pagedFinder, - Function countOperation, Function existsOperation, - EntityManager entityManager, ProjectionFactory projectionFactory) { + private FetchableFluentQueryByPredicate(EntityPath entityPath, Predicate predicate, + JpaEntityInformation entityInformation, Class resultType, Sort sort, int limit, + Collection properties, Function> finder, + ScrollQueryFactory> scrollQueryFactory, + BiFunction> pagedFinder, Function countOperation, + Function existsOperation, EntityManager entityManager, ProjectionFactory projectionFactory) { - super(resultType, sort, limit, properties, entityType, projectionFactory); + super(resultType, sort, limit, properties, entityInformation.getJavaType(), projectionFactory); + this.entityInformation = entityInformation; + this.entityPath = entityPath; this.predicate = predicate; this.finder = finder; - this.scroll = scroll; + this.scrollQueryFactory = scrollQueryFactory; this.pagedFinder = pagedFinder; this.countOperation = countOperation; this.existsOperation = existsOperation; @@ -95,8 +113,9 @@ public FetchableFluentQuery sortBy(Sort sort) { Assert.notNull(sort, "Sort must not be null"); - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, this.sort.and(sort), limit, - properties, finder, scroll, pagedFinder, countOperation, existsOperation, entityManager, projectionFactory); + return new FetchableFluentQueryByPredicate<>(entityPath, predicate, entityInformation, resultType, + this.sort.and(sort), limit, properties, finder, scrollQueryFactory, pagedFinder, countOperation, + existsOperation, entityManager, projectionFactory); } @Override @@ -104,8 +123,9 @@ public FetchableFluentQuery limit(int limit) { Assert.isTrue(limit >= 0, "Limit must not be negative"); - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, limit, properties, finder, - scroll, pagedFinder, countOperation, existsOperation, entityManager, projectionFactory); + return new FetchableFluentQueryByPredicate<>(entityPath, predicate, entityInformation, resultType, sort, limit, + properties, finder, scrollQueryFactory, pagedFinder, countOperation, existsOperation, entityManager, + projectionFactory); } @Override @@ -113,26 +133,23 @@ public FetchableFluentQuery as(Class resultType) { Assert.notNull(resultType, "Projection target type must not be null"); - if (!resultType.isInterface()) { - throw new UnsupportedOperationException("Class-based DTOs are not yet supported."); - } - - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, limit, properties, finder, - scroll, pagedFinder, countOperation, existsOperation, entityManager, projectionFactory); + return new FetchableFluentQueryByPredicate<>(entityPath, predicate, entityInformation, resultType, sort, limit, + properties, finder, scrollQueryFactory, pagedFinder, countOperation, existsOperation, entityManager, + projectionFactory); } @Override public FetchableFluentQuery project(Collection properties) { - return new FetchableFluentQueryByPredicate<>(predicate, entityType, resultType, sort, limit, - mergeProperties(properties), finder, scroll, pagedFinder, countOperation, existsOperation, entityManager, - projectionFactory); + return new FetchableFluentQueryByPredicate<>(entityPath, predicate, entityInformation, resultType, sort, limit, + mergeProperties(properties), finder, scrollQueryFactory, pagedFinder, countOperation, existsOperation, + entityManager, projectionFactory); } @Override - public R oneValue() { + public @Nullable R oneValue() { - List results = createSortedAndProjectedQuery() // + List results = createSortedAndProjectedQuery(this.sort) // .limit(2) // Never need more than 2 values .fetch(); @@ -144,9 +161,9 @@ public R oneValue() { } @Override - public R firstValue() { + public @Nullable R firstValue() { - List results = createSortedAndProjectedQuery() // + List results = createSortedAndProjectedQuery(this.sort) // .limit(1) // Never need more than 1 value .fetch(); @@ -155,7 +172,11 @@ public R firstValue() { @Override public List all() { - return convert(createSortedAndProjectedQuery().fetch()); + return all(this.sort); + } + + private List all(Sort sort) { + return convert(createSortedAndProjectedQuery(sort).fetch()); } @Override @@ -163,18 +184,24 @@ public Window scroll(ScrollPosition scrollPosition) { Assert.notNull(scrollPosition, "ScrollPosition must not be null"); - return scroll.scroll(sort, limit, scrollPosition).map(getConversionFunction()); + return new PredicateScrollDelegate<>(scrollQueryFactory, entityInformation) + .scroll(returnedType, sort, limit, scrollPosition).map(getConversionFunction()); } @Override public Page page(Pageable pageable) { - return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable); + return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSortOr(this.sort))) : readPage(pageable); + } + + @Override + public Slice slice(Pageable pageable) { + return pageable.isUnpaged() ? new SliceImpl<>(all(pageable.getSortOr(this.sort))) : readSlice(pageable); } @Override public Stream stream() { - return createSortedAndProjectedQuery() // + return createSortedAndProjectedQuery(this.sort) // .stream() // .map(getConversionFunction()); } @@ -189,9 +216,36 @@ public boolean exists() { return existsOperation.apply(predicate); } - private AbstractJPAQuery createSortedAndProjectedQuery() { + private AbstractJPAQuery createSortedAndProjectedQuery(Sort sort) { AbstractJPAQuery query = finder.apply(sort); + applyQuerySettings(this.returnedType, this.limit, query, null); + return query; + } + + private void applyQuerySettings(ReturnedType returnedType, int limit, AbstractJPAQuery query, + @Nullable ScrollPosition scrollPosition) { + + List inputProperties = returnedType.getInputProperties(); + + if (returnedType.needsCustomConstruction()) { + + Collection requiredSelection; + if (scrollPosition instanceof KeysetScrollPosition && returnedType.getReturnedType().isInterface()) { + requiredSelection = KeysetScrollDelegate.getProjectionInputProperties(entityInformation, inputProperties, sort); + } else { + requiredSelection = inputProperties; + } + + PathBuilder builder = new PathBuilder<>(entityPath.getType(), entityPath.getMetadata()); + Expression[] projection = requiredSelection.stream().map(builder::get).toArray(Expression[]::new); + + if (returnedType.getReturnedType().isInterface()) { + query.select(new JakartaTuple(projection)); + } else { + query.select(new DtoProjection(returnedType.getReturnedType(), projection)); + } + } if (!properties.isEmpty()) { query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties)); @@ -200,21 +254,45 @@ public boolean exists() { if (limit != 0) { query.limit(limit); } - - return query; } private Page readPage(Pageable pageable) { + Sort sort = pageable.getSortOr(this.sort); + AbstractJPAQuery query = createQuery(pageable, sort); + + List paginatedResults = convert(query.fetch()); + + return PageableExecutionUtils.getPage(paginatedResults, withSort(pageable, sort), + () -> countOperation.apply(predicate)); + } + + private Slice readSlice(Pageable pageable) { + + Sort sort = pageable.getSortOr(this.sort); + AbstractJPAQuery query = createQuery(pageable, sort); + query.limit(pageable.getPageSize() + 1); + + List resultList = query.fetch(); + boolean hasNext = resultList.size() > pageable.getPageSize(); + if (hasNext) { + resultList = resultList.subList(0, pageable.getPageSize()); + } + + List slice = convert(resultList); + + return new SliceImpl<>(slice, pageable, hasNext); + } + + private AbstractJPAQuery createQuery(Pageable pageable, Sort sort) { + AbstractJPAQuery query = pagedFinder.apply(sort, pageable); if (!properties.isEmpty()) { query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties)); } - List paginatedResults = convert(query.fetch()); - - return PageableExecutionUtils.getPage(paginatedResults, pageable, () -> countOperation.apply(predicate)); + return query; } private List convert(List resultList) { @@ -233,23 +311,60 @@ private Function getConversionFunction() { return getConversionFunction(entityType, resultType); } - static class PredicateScrollDelegate extends ScrollDelegate { + class PredicateScrollDelegate extends ScrollDelegate { - private final ScrollQueryFactory scrollFunction; + private final ScrollQueryFactory> scrollFunction; - PredicateScrollDelegate(ScrollQueryFactory scrollQueryFactory, JpaEntityInformation entity) { + PredicateScrollDelegate(ScrollQueryFactory> scrollQueryFactory, + JpaEntityInformation entity) { super(entity); this.scrollFunction = scrollQueryFactory; } - public Window scroll(Sort sort, int limit, ScrollPosition scrollPosition) { + public Window scroll(ReturnedType returnedType, Sort sort, int limit, ScrollPosition scrollPosition) { - Query query = scrollFunction.createQuery(sort, scrollPosition); - if (limit > 0) { - query = query.setMaxResults(limit); - } - return scroll(query, sort, scrollPosition); + AbstractJPAQuery query = scrollFunction.createQuery(FetchableFluentQueryByPredicate.this, scrollPosition); + + applyQuerySettings(returnedType, limit, query, scrollPosition); + + return scroll(query.createQuery(), sort, scrollPosition); } } + /** + * @since 3.5 + */ + private static class DtoProjection extends ExpressionBase { + + private final Expression[] projection; + + public DtoProjection(Class resultType, Expression[] projection) { + super(resultType); + this.projection = projection; + } + + @SuppressWarnings("unchecked") + @Override + public R accept(Visitor v, @Nullable C context) { + + if (v instanceof JPQLSerializer s) { + + s.append("new ").append(getType().getName()).append("("); + boolean first = true; + for (Expression expression : projection) { + if (first) { + first = false; + } else { + s.append(", "); + } + + expression.accept(v, context); + } + + s.append(")"); + } + + return (R) this; + } + } } 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 6121be4374..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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. @@ -26,14 +26,19 @@ 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; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Window; import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor.SpecificationFluentQuery; import org.springframework.data.jpa.repository.query.ScrollDelegate; import org.springframework.data.jpa.support.PageableUtils; import org.springframework.data.projection.ProjectionFactory; @@ -52,25 +57,25 @@ * @since 3.0 */ class FetchableFluentQueryBySpecification extends FluentQuerySupport - implements FluentQuery.FetchableFluentQuery { + implements FluentQuery.FetchableFluentQuery, SpecificationFluentQuery { private final Specification spec; - private final Function> finder; + private final Function, TypedQuery> finder; private final SpecificationScrollDelegate scroll; private final Function, Long> countOperation; private final Function, Boolean> existsOperation; private final EntityManager entityManager; - FetchableFluentQueryBySpecification(Specification spec, Class entityType, Function> finder, - SpecificationScrollDelegate scrollDelegate, Function, Long> countOperation, - Function, Boolean> existsOperation, EntityManager entityManager, - ProjectionFactory projectionFactory) { + FetchableFluentQueryBySpecification(Specification spec, Class entityType, + Function, TypedQuery> finder, SpecificationScrollDelegate scrollDelegate, + Function, Long> countOperation, Function, Boolean> existsOperation, + EntityManager entityManager, ProjectionFactory projectionFactory) { this(spec, entityType, (Class) entityType, Sort.unsorted(), 0, Collections.emptySet(), finder, scrollDelegate, countOperation, existsOperation, entityManager, projectionFactory); } private FetchableFluentQueryBySpecification(Specification spec, Class entityType, Class resultType, - Sort sort, int limit, Collection properties, Function> finder, + Sort sort, int limit, Collection properties, Function, TypedQuery> finder, SpecificationScrollDelegate scrollDelegate, Function, Long> countOperation, Function, Boolean> existsOperation, EntityManager entityManager, ProjectionFactory projectionFactory) { @@ -85,46 +90,52 @@ private FetchableFluentQueryBySpecification(Specification spec, Class enti } @Override - public FetchableFluentQuery sortBy(Sort sort) { + public SpecificationFluentQuery sortBy(Sort sort) { Assert.notNull(sort, "Sort must not be null"); - return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, this.sort.and(sort), limit, - properties, finder, scroll, countOperation, existsOperation, entityManager, projectionFactory); + return getSorted(this.sort.and(sort)); + } + + private FetchableFluentQueryBySpecification getSorted(Sort sort) { + + if (this.sort == sort) { + return this; + } + + return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, limit, properties, finder, + scroll, countOperation, existsOperation, entityManager, projectionFactory); } @Override - public FetchableFluentQuery limit(int limit) { + public SpecificationFluentQuery limit(int limit) { Assert.isTrue(limit >= 0, "Limit must not be negative"); - return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, this.sort.and(sort), limit, - properties, finder, scroll, countOperation, existsOperation, entityManager, projectionFactory); + return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, limit, properties, finder, + scroll, countOperation, existsOperation, entityManager, projectionFactory); } @Override - public FetchableFluentQuery as(Class resultType) { + public SpecificationFluentQuery as(Class resultType) { Assert.notNull(resultType, "Projection target type must not be null"); - if (!resultType.isInterface()) { - throw new UnsupportedOperationException("Class-based DTOs are not yet supported."); - } return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, limit, properties, finder, scroll, countOperation, existsOperation, entityManager, projectionFactory); } @Override - public FetchableFluentQuery project(Collection properties) { + public SpecificationFluentQuery project(Collection properties) { return new FetchableFluentQueryBySpecification<>(spec, entityType, resultType, sort, limit, properties, finder, scroll, countOperation, existsOperation, entityManager, projectionFactory); } @Override - public R oneValue() { + public @Nullable R oneValue() { - List results = createSortedAndProjectedQuery() // + List results = createSortedAndProjectedQuery(this.sort) // .setMaxResults(2) // Never need more than 2 values .getResultList(); @@ -136,9 +147,9 @@ public R oneValue() { } @Override - public R firstValue() { + public @Nullable R firstValue() { - List results = createSortedAndProjectedQuery() // + List results = createSortedAndProjectedQuery(this.sort) // .setMaxResults(1) // Never need more than 1 value .getResultList(); @@ -147,7 +158,11 @@ public R firstValue() { @Override public List all() { - return convert(createSortedAndProjectedQuery().getResultList()); + return all(this.sort); + } + + private List all(Sort sort) { + return convert(createSortedAndProjectedQuery(sort).getResultList()); } @Override @@ -155,18 +170,30 @@ public Window scroll(ScrollPosition scrollPosition) { Assert.notNull(scrollPosition, "ScrollPosition must not be null"); - return scroll.scroll(sort, limit, scrollPosition).map(getConversionFunction()); + return scroll.scroll(this, scrollPosition).map(getConversionFunction()); + } + + @Override + public Slice slice(Pageable pageable) { + return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSort())) : readSlice(pageable); } @Override public Page page(Pageable pageable) { - return pageable.isUnpaged() ? new PageImpl<>(all()) : readPage(pageable); + return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSort())) : readPage(pageable, spec); + } + + @Override + @SuppressWarnings({ "rawtypes", "unchecked" }) + public Page page(Pageable pageable, Specification countSpec) { + return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSort())) + : readPage(pageable, (Specification) countSpec); } @Override public Stream stream() { - return createSortedAndProjectedQuery() // + return createSortedAndProjectedQuery(this.sort) // .getResultStream() // .map(getConversionFunction()); } @@ -181,9 +208,9 @@ public boolean exists() { return existsOperation.apply(spec); } - private TypedQuery createSortedAndProjectedQuery() { + private TypedQuery createSortedAndProjectedQuery(Sort sort) { - TypedQuery query = finder.apply(sort); + TypedQuery query = finder.apply(getSorted(sort)); if (!properties.isEmpty()) { query.setHint(EntityGraphFactory.HINT, EntityGraphFactory.create(entityManager, entityType, properties)); @@ -196,9 +223,50 @@ private TypedQuery createSortedAndProjectedQuery() { return query; } - private Page readPage(Pageable pageable) { + private Slice readSlice(Pageable pageable) { + + TypedQuery pagedQuery = createSortedAndProjectedQuery(pageable.getSort()); + + if (pageable.isPaged()) { + pagedQuery.setFirstResult(PageableUtils.getOffsetAsInteger(pageable)); + pagedQuery.setMaxResults(pageable.getPageSize() + 1); + } + + List resultList = pagedQuery.getResultList(); + boolean hasNext = resultList.size() > pageable.getPageSize(); + if (hasNext) { + resultList = resultList.subList(0, pageable.getPageSize()); + } + + List slice = convert(resultList); + + return new SliceImpl<>(slice, pageable, hasNext); + } + + private Slice readSlice(Pageable pageable, @Nullable Specification countSpec) { + + TypedQuery pagedQuery = createSortedAndProjectedQuery(pageable.getSort()); + + if (pageable.isPaged()) { + pagedQuery.setFirstResult(PageableUtils.getOffsetAsInteger(pageable)); + pagedQuery.setMaxResults(pageable.getPageSize() + 1); + } + + List resultList = pagedQuery.getResultList(); + boolean hasNext = resultList.size() > pageable.getPageSize(); + if (hasNext) { + resultList = resultList.subList(0, pageable.getPageSize()); + } + + List slice = convert(resultList); + + return new SliceImpl<>(slice, pageable, hasNext); + } + + private Page readPage(Pageable pageable, @Nullable Specification countSpec) { - TypedQuery pagedQuery = createSortedAndProjectedQuery(); + Sort sort = pageable.getSortOr(this.sort); + TypedQuery pagedQuery = createSortedAndProjectedQuery(sort); if (pageable.isPaged()) { pagedQuery.setFirstResult(PageableUtils.getOffsetAsInteger(pageable)); @@ -207,7 +275,8 @@ private Page readPage(Pageable pageable) { List paginatedResults = convert(pagedQuery.getResultList()); - return PageableExecutionUtils.getPage(paginatedResults, pageable, () -> countOperation.apply(spec)); + return PageableExecutionUtils.getPage(paginatedResults, withSort(pageable, sort), + () -> countOperation.apply(countSpec)); } private List convert(List resultList) { @@ -227,22 +296,23 @@ private Function getConversionFunction() { static class SpecificationScrollDelegate extends ScrollDelegate { - private final ScrollQueryFactory scrollFunction; + private final ScrollQueryFactory> scrollFunction; - SpecificationScrollDelegate(ScrollQueryFactory scrollQueryFactory, JpaEntityInformation entity) { + SpecificationScrollDelegate(ScrollQueryFactory> scrollQueryFactory, + JpaEntityInformation entity) { super(entity); this.scrollFunction = scrollQueryFactory; } - public Window scroll(Sort sort, int limit, ScrollPosition scrollPosition) { + public Window scroll(FluentQuerySupport q, ScrollPosition scrollPosition) { - Query query = scrollFunction.createQuery(sort, scrollPosition); + Query query = scrollFunction.createQuery(q, scrollPosition); - if (limit > 0) { - query = query.setMaxResults(limit); + if (q.limit > 0) { + query = query.setMaxResults(q.limit); } - return scroll(query, sort, scrollPosition); + return scroll(query, q.sort, scrollPosition); } } } 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 5917a119f5..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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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,8 +15,6 @@ */ package org.springframework.data.jpa.repository.support; -import jakarta.persistence.Query; - import java.util.Collection; import java.util.Collections; import java.util.HashSet; @@ -24,10 +22,15 @@ 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; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.query.AbstractJpaQuery; import org.springframework.data.projection.ProjectionFactory; -import org.springframework.lang.Nullable; +import org.springframework.data.repository.query.ReturnedType; /** * Supporting class containing some state and convenience methods for building and executing fluent queries. @@ -41,6 +44,7 @@ */ abstract class FluentQuerySupport { + protected final ReturnedType returnedType; protected final Class resultType; protected final Sort sort; protected final int limit; @@ -49,8 +53,9 @@ abstract class FluentQuerySupport { protected final ProjectionFactory projectionFactory; FluentQuerySupport(Class resultType, Sort sort, int limit, @Nullable Collection properties, - Class entityType, ProjectionFactory projectionFactory) { + Class entityType, ProjectionFactory projectionFactory) { + this.returnedType = ReturnedType.of(resultType, entityType, projectionFactory); this.resultType = resultType; this.sort = sort; this.limit = limit; @@ -80,15 +85,29 @@ final Function getConversionFunction(Class inputType, Class tar return (Function) Function.identity(); } - if (targetType.isInterface()) { - return o -> projectionFactory.createProjection(targetType, o); + if (returnedType.isProjecting()) { + + AbstractJpaQuery.TupleConverter tupleConverter = new AbstractJpaQuery.TupleConverter(returnedType); + + if (resultType.isInterface()) { + return o -> projectionFactory.createProjection(targetType, tupleConverter.convert(o)); + } } return o -> DefaultConversionService.getSharedInstance().convert(o, targetType); } - interface ScrollQueryFactory { - Query createQuery(Sort sort, ScrollPosition scrollPosition); + Pageable withSort(Pageable pageable, Sort sort) { + + if (pageable instanceof PageRequest pr && pageable.getSort() != sort) { + return pr.withSort(sort); + } + + return pageable; + } + + interface ScrollQueryFactory { + Q createQuery(FluentQuerySupport query, ScrollPosition scrollPosition); } } 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 new file mode 100644 index 0000000000..6b2e6361e9 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java @@ -0,0 +1,94 @@ +/* + * 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 jakarta.persistence.Tuple; + +import java.util.Arrays; +import java.util.List; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.ExpressionBase; +import com.querydsl.core.types.ExpressionUtils; +import com.querydsl.core.types.FactoryExpression; +import com.querydsl.core.types.Ops; +import com.querydsl.core.types.Path; +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 + * being a {@link com.querydsl.core.types.FactoryExpressionBase} as we do not want Querydsl to instantiate any tuples. + * JPA is doing that for us. + * + * @author Mark Paluch + * @since 3.5 + */ +@SuppressWarnings("unchecked") +class JakartaTuple extends ExpressionBase { + + private final List> args; + + /** + * Create a new JakartaTuple instance + * + * @param args + */ + protected JakartaTuple(Expression... args) { + this(Arrays.asList(args)); + } + + /** + * Create a new JakartaTuple instance + * + * @param args + */ + protected JakartaTuple(List> args) { + super(Tuple.class); + this.args = args.stream().map(it -> { + + if (it instanceof Path p) { + return ExpressionUtils.operation(p.getType(), Ops.ALIAS, p, p); + } + + return it; + }).toList(); + } + + @Override + public @Nullable R accept(Visitor v, @Nullable C context) { + + if (v instanceof JPQLSerializer) { + return Projections.tuple(args).accept(v, context); + } + + return (R) this; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) { + return true; + } else if (obj instanceof FactoryExpression c) { + return args.equals(c.getArgs()) && getType().equals(c.getType()); + } else { + return false; + } + } + +} 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 096bd77d21..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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -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 00c9f7f27d..4572f8bf9c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -17,6 +17,8 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceUnitUtil; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.ManagedType; import jakarta.persistence.metamodel.Metamodel; import org.springframework.data.domain.Persistable; @@ -35,7 +37,7 @@ public abstract class JpaEntityInformationSupport extends AbstractEntityInformation implements JpaEntityInformation { - private JpaEntityMetadata metadata; + private final JpaEntityMetadata metadata; /** * Creates a new {@link JpaEntityInformationSupport} with the given domain class. @@ -43,8 +45,17 @@ public abstract class JpaEntityInformationSupport extends AbstractEntityI * @param domainClass must not be {@literal null}. */ public JpaEntityInformationSupport(Class domainClass) { - super(domainClass); - this.metadata = new DefaultJpaEntityMetadata<>(domainClass); + this(new DefaultJpaEntityMetadata<>(domainClass)); + } + + /** + * Creates a new {@link JpaEntityInformationSupport} with the given {@link JpaEntityMetadata}. + * + * @param metadata must not be {@literal null}. + */ + public JpaEntityInformationSupport(JpaEntityMetadata metadata) { + super(metadata.getJavaType()); + this.metadata = metadata; } /** @@ -54,14 +65,39 @@ public JpaEntityInformationSupport(Class domainClass) { * @param em must not be {@literal null}. * @return */ - @SuppressWarnings({ "rawtypes", "unchecked" }) public static JpaEntityInformation getEntityInformation(Class domainClass, EntityManager em) { Assert.notNull(domainClass, "Domain class must not be null"); Assert.notNull(em, "EntityManager must not be null"); - Metamodel metamodel = em.getMetamodel(); - PersistenceUnitUtil persistenceUnitUtil = em.getEntityManagerFactory().getPersistenceUnitUtil(); + return getEntityInformation(domainClass, em.getMetamodel(), em.getEntityManagerFactory().getPersistenceUnitUtil()); + } + + /** + * Creates a {@link JpaEntityInformation} for the given domain class and {@link Metamodel}. + * + * @param domainClass must not be {@literal null}. + * @param metamodel must not be {@literal null}. + * @param persistenceUnitUtil must not be {@literal null}. + * @return + * @since 4.0 + */ + @SuppressWarnings({ "rawtypes", "unchecked" }) + public static JpaEntityInformation getEntityInformation(Class domainClass, Metamodel metamodel, + PersistenceUnitUtil persistenceUnitUtil) { + + Assert.notNull(domainClass, "Domain class must not be null"); + Assert.notNull(metamodel, "Metamodel must not be null"); + + ManagedType type = metamodel.managedType(domainClass); + + if (type instanceof EntityType entityType) { + if (Persistable.class.isAssignableFrom(domainClass)) { + return new JpaPersistableEntityInformation(entityType, metamodel, persistenceUnitUtil); + } else { + return new JpaMetamodelEntityInformation(entityType, metamodel, persistenceUnitUtil); + } + } if (Persistable.class.isAssignableFrom(domainClass)) { return new JpaPersistableEntityInformation(domainClass, metamodel, persistenceUnitUtil); 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 79b7b10de0..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 @@ -1,5 +1,5 @@ /* - * Copyright 2019-2024 the original author or authors. + * Copyright 2019-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,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 9979fc773b..b1b8bb784c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -37,12 +37,14 @@ import java.util.Set; import java.util.function.Function; +import org.jspecify.annotations.Nullable; + import org.springframework.beans.BeanWrapper; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.jpa.repository.query.JpaMetamodelEntityMetadata; import org.springframework.data.jpa.util.JpaMetamodel; import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -98,6 +100,28 @@ public JpaMetamodelEntityInformation(Class domainClass, Metamodel metamodel, this.persistenceUnitUtil = persistenceUnitUtil; } + /** + * Creates a new {@link JpaMetamodelEntityInformation} for the given {@link Metamodel}. + * + * @param entityType must not be {@literal null}. + * @param metamodel must not be {@literal null}. + * @param persistenceUnitUtil must not be {@literal null}. + * @since 4.0 + */ + JpaMetamodelEntityInformation(EntityType entityType, Metamodel metamodel, + PersistenceUnitUtil persistenceUnitUtil) { + + super(new JpaMetamodelEntityMetadata<>(entityType)); + + this.metamodel = metamodel; + this.entityName = entityType.getName(); + this.idMetadata = new IdMetadata<>(entityType, PersistenceProvider.fromMetamodel(metamodel)); + this.versionAttribute = findVersionAttribute(entityType, metamodel); + + Assert.notNull(persistenceUnitUtil, "PersistenceUnitUtil must not be null"); + this.persistenceUnitUtil = persistenceUnitUtil; + } + @Override public String getEntityName() { return entityName != null ? entityName : super.getEntityName(); @@ -143,9 +167,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); @@ -161,7 +184,9 @@ public ID getId(T entity) { return (ID) t.get(idMetadata.getSimpleIdAttribute().getName()); } - return (ID) persistenceUnitUtil.getIdentifier(entity); + if (getJavaType().isInstance(entity)) { + return (ID) persistenceUnitUtil.getIdentifier(entity); + } } // otherwise, check if the complex id type has any partially filled fields @@ -172,6 +197,10 @@ public ID getId(T entity) { Object propertyValue = entityWrapper.getPropertyValue(attribute.getName()); + if (idMetadata.hasSimpleId()) { + return (ID) propertyValue; + } + if (propertyValue != null) { partialIdValueFound = true; } @@ -209,7 +238,7 @@ public Collection 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"); @@ -306,8 +335,7 @@ public Class getType() { return this.idType; } - @Nullable - private Class tryExtractIdTypeWithFallbackToIdTypeLookup() { + private @Nullable Class tryExtractIdTypeWithFallbackToIdTypeLookup() { try { @@ -324,8 +352,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 ed0b4644ec..9b214a76ff 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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -16,10 +16,12 @@ package org.springframework.data.jpa.repository.support; import jakarta.persistence.PersistenceUnitUtil; +import jakarta.persistence.metamodel.EntityType; import jakarta.persistence.metamodel.Metamodel; +import org.jspecify.annotations.Nullable; + import org.springframework.data.domain.Persistable; -import org.springframework.lang.Nullable; /** * Extension of {@link JpaMetamodelEntityInformation} that consideres methods of {@link Persistable} to lookup the id. @@ -33,7 +35,7 @@ public class JpaPersistableEntityInformation, ID> /** * Creates a new {@link JpaPersistableEntityInformation} for the given domain class and {@link Metamodel}. - * + * * @param domainClass must not be {@literal null}. * @param metamodel must not be {@literal null}. * @param persistenceUnitUtil must not be {@literal null}. @@ -43,14 +45,26 @@ public JpaPersistableEntityInformation(Class domainClass, Metamodel metamodel super(domainClass, metamodel, persistenceUnitUtil); } + /** + * Creates a new {@link JpaPersistableEntityInformation} for the given {@link Metamodel}. + * + * @param entityType must not be {@literal null}. + * @param metamodel must not be {@literal null}. + * @param persistenceUnitUtil must not be {@literal null}. + * @since 4.0 + */ + JpaPersistableEntityInformation(EntityType entityType, Metamodel metamodel, + PersistenceUnitUtil persistenceUnitUtil) { + super(entityType, metamodel, persistenceUnitUtil); + } + @Override 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/JpaRepositoryConfigurationAware.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryConfigurationAware.java index d5b497f9a2..b8d6e2a587 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryConfigurationAware.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryConfigurationAware.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 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. 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 bffdafd46b..2729f085e7 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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,40 +25,32 @@ 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.EntityInformation; 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.QueryMethodEvaluationContextProvider; import org.springframework.data.repository.query.ReturnedType; -import org.springframework.lang.Nullable; +import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; @@ -81,12 +71,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; @@ -100,7 +91,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); @@ -122,7 +113,7 @@ public JpaRepositoryFactory(EntityManager entityManager) { } @Override - public void setBeanClassLoader(ClassLoader classLoader) { + public void setBeanClassLoader(@Nullable ClassLoader classLoader) { super.setBeanClassLoader(classLoader); this.crudMethodMetadataPostProcessor.setBeanClassLoader(classLoader); @@ -166,6 +157,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}. * @@ -178,6 +180,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)}. @@ -225,70 +240,71 @@ 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; } @Override protected Optional getQueryLookupStrategy(@Nullable Key key, - QueryMethodEvaluationContextProvider evaluationContextProvider) { + ValueExpressionDelegate valueExpressionDelegate) { - return Optional.of(JpaQueryLookupStrategy.create(entityManager, queryMethodFactory, key, evaluationContextProvider, - queryRewriterProvider, escapeCharacter)); + JpaQueryConfiguration queryConfiguration = new JpaQueryConfiguration(queryRewriterProvider, queryEnhancerSelector, + new CachingValueExpressionDelegate(valueExpressionDelegate), escapeCharacter); + + return Optional.of(JpaQueryLookupStrategy.create(entityManager, queryMethodFactory, key, queryConfiguration)); } @Override @SuppressWarnings("unchecked") public JpaEntityInformation getEntityInformation(Class domainClass) { - return (JpaEntityInformation) JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager); } @Override - protected RepositoryFragments getRepositoryFragments(RepositoryMetadata metadata) { + public EntityInformation getEntityInformation(RepositoryMetadata metadata) { + return JpaEntityInformationSupport.getEntityInformation(metadata.getDomainType(), 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. *

      - * 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 f75f45a8d1..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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, S, ID> extends TransactionalRepositoryFactoryBeanSupport { + 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 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 queryEnhancerSelectorType) { + + this.queryEnhancerSelectorSource = bf -> { + + if (bf != null) { + + ObjectProvider 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 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. + *

      + * Implementations must define a no-args constructor. + *

      + * 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/JpaRepositoryImplementation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryImplementation.java index d3a5b1c5fe..006b39a689 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryImplementation.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryImplementation.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. 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 ignoreCaseFunction; + + JpqlQueryTemplates(String ignoreCaseOperator, Function ignoreCaseFunction) { + this.ignoreCaseOperator = ignoreCaseOperator; + this.ignoreCaseFunction = ignoreCaseFunction; + } + + public static JpqlQueryTemplates of(String ignoreCaseOperator, Function 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/MutableQueryHints.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/MutableQueryHints.java index 4a1e7443b6..46b7a7c77d 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/MutableQueryHints.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/MutableQueryHints.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QueryHintValue.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QueryHintValue.java index c32b40f767..0d01e738a8 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QueryHintValue.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QueryHintValue.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-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. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QueryHints.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QueryHints.java index 17e6d01725..8afea10892 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QueryHints.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QueryHints.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. 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 eaadddb2a3..1e493eb905 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -37,6 +37,7 @@ import com.querydsl.jpa.EclipseLinkTemplates; import com.querydsl.jpa.HQLTemplates; import com.querydsl.jpa.JPQLQuery; +import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.AbstractJPAQuery; import com.querydsl.jpa.impl.JPAQuery; @@ -77,15 +78,25 @@ public Querydsl(EntityManager em, PathBuilder builder) { */ public AbstractJPAQuery> createQuery() { - switch (provider) { - case ECLIPSELINK: - return new JPAQuery<>(em, EclipseLinkTemplates.DEFAULT); - case HIBERNATE: - return new JPAQuery<>(em, HQLTemplates.DEFAULT); - case GENERIC_JPA: - default: - return new JPAQuery<>(em); - } + JPQLTemplates templates = getTemplates(); + return templates != null ? new SpringDataJpaQuery<>(em, templates) : new SpringDataJpaQuery<>(em); + } + + /** + * 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}, {@link JPQLTemplates#DEFAULT} by + * default. + * @since 3.5 + */ + public JPQLTemplates getTemplates() { + + return switch (provider) { + case ECLIPSELINK -> EclipseLinkTemplates.DEFAULT; + case HIBERNATE -> HQLTemplates.DEFAULT; + default -> JPQLTemplates.DEFAULT; + }; } /** @@ -112,12 +123,11 @@ public JPQLQuery applyPagination(Pageable pageable, JPQLQuery query) { Assert.notNull(pageable, "Pageable must not be null"); Assert.notNull(query, "JPQLQuery must not be null"); - if (pageable.isUnpaged()) { - return query; - } + if (pageable.isPaged()) { - query.offset(pageable.getOffset()); - query.limit(pageable.getPageSize()); + query.offset(pageable.getOffset()); + query.limit(pageable.getPageSize()); + } return applySorting(pageable.getSort(), query); } @@ -202,18 +212,11 @@ private NullHandling toQueryDslNullHandling(org.springframework.data.domain.Sort Assert.notNull(nullHandling, "NullHandling must not be null"); - switch (nullHandling) { - - case NULLS_FIRST: - return NullHandling.NullsFirst; - - case NULLS_LAST: - return NullHandling.NullsLast; - - case NATIVE: - default: - return NullHandling.Default; - } + return switch (nullHandling) { + case NULLS_FIRST -> NullHandling.NullsFirst; + case NULLS_LAST -> NullHandling.NullsLast; + default -> NullHandling.Default; + }; } /** 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 c16f95c0a1..3d95f6280e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -23,7 +23,10 @@ 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; import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.Page; @@ -34,16 +37,15 @@ import org.springframework.data.jpa.repository.query.KeysetScrollDelegate; import org.springframework.data.jpa.repository.query.KeysetScrollDelegate.QueryStrategy; import org.springframework.data.jpa.repository.query.KeysetScrollSpecification; -import org.springframework.data.jpa.repository.support.FetchableFluentQueryByPredicate.PredicateScrollDelegate; import org.springframework.data.jpa.repository.support.FluentQuerySupport.ScrollQueryFactory; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.querydsl.EntityPathResolver; import org.springframework.data.querydsl.QSort; import org.springframework.data.querydsl.QuerydslPredicateExecutor; +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; @@ -75,6 +77,12 @@ */ public class QuerydslJpaPredicateExecutor implements QuerydslPredicateExecutor, JpaRepositoryConfigurationAware { + private static final String PREDICATE_MUST_NOT_BE_NULL = "Predicate must not be null"; + private static final String ORDER_SPECIFIERS_MUST_NOT_BE_NULL = "Order specifiers must not be null"; + private static final String SORT_MUST_NOT_BE_NULL = "Sort must not be null"; + private static final String PAGEABLE_MUST_NOT_BE_NULL = "Pageable must not be null"; + private static final String QUERY_FUNCTION_MUST_NOT_BE_NULL = "Query function must not be null"; + private final JpaEntityInformation entityInformation; private final EntityPath path; private final Querydsl querydsl; @@ -116,7 +124,7 @@ public void setProjectionFactory(ProjectionFactory projectionFactory) { @Override public Optional findOne(Predicate predicate) { - Assert.notNull(predicate, "Predicate must not be null"); + Assert.notNull(predicate, PREDICATE_MUST_NOT_BE_NULL); try { return Optional.ofNullable(createQuery(predicate).select(path).limit(2).fetchOne()); @@ -128,7 +136,7 @@ public Optional findOne(Predicate predicate) { @Override public List findAll(Predicate predicate) { - Assert.notNull(predicate, "Predicate must not be null"); + Assert.notNull(predicate, PREDICATE_MUST_NOT_BE_NULL); return createQuery(predicate).select(path).fetch(); } @@ -136,8 +144,8 @@ public List findAll(Predicate predicate) { @Override public List findAll(Predicate predicate, OrderSpecifier... orders) { - Assert.notNull(predicate, "Predicate must not be null"); - Assert.notNull(orders, "Order specifiers must not be null"); + Assert.notNull(predicate, PREDICATE_MUST_NOT_BE_NULL); + Assert.notNull(orders, ORDER_SPECIFIERS_MUST_NOT_BE_NULL); return executeSorted(createQuery(predicate).select(path), orders); } @@ -145,8 +153,8 @@ public List findAll(Predicate predicate, OrderSpecifier... orders) { @Override public List findAll(Predicate predicate, Sort sort) { - Assert.notNull(predicate, "Predicate must not be null"); - Assert.notNull(sort, "Sort must not be null"); + Assert.notNull(predicate, PREDICATE_MUST_NOT_BE_NULL); + Assert.notNull(sort, SORT_MUST_NOT_BE_NULL); return executeSorted(createQuery(predicate).select(path), sort); } @@ -154,7 +162,7 @@ public List findAll(Predicate predicate, Sort sort) { @Override public List findAll(OrderSpecifier... orders) { - Assert.notNull(orders, "Order specifiers must not be null"); + Assert.notNull(orders, ORDER_SPECIFIERS_MUST_NOT_BE_NULL); return executeSorted(createQuery(new Predicate[0]).select(path), orders); } @@ -162,8 +170,8 @@ public List findAll(OrderSpecifier... orders) { @Override public Page findAll(Predicate predicate, Pageable pageable) { - Assert.notNull(predicate, "Predicate must not be null"); - Assert.notNull(pageable, "Pageable must not be null"); + Assert.notNull(predicate, PREDICATE_MUST_NOT_BE_NULL); + Assert.notNull(pageable, PAGEABLE_MUST_NOT_BE_NULL); final JPQLQuery countQuery = createCountQuery(predicate); JPQLQuery query = querydsl.applyPagination(pageable, createQuery(predicate).select(path)); @@ -173,10 +181,11 @@ public Page findAll(Predicate predicate, Pageable pageable) { @SuppressWarnings("unchecked") @Override - public R findBy(Predicate predicate, Function, R> queryFunction) { + public R findBy(Predicate predicate, + Function, R> queryFunction) { - Assert.notNull(predicate, "Predicate must not be null"); - Assert.notNull(queryFunction, "Query function must not be null"); + Assert.notNull(predicate, PREDICATE_MUST_NOT_BE_NULL); + Assert.notNull(queryFunction, QUERY_FUNCTION_MUST_NOT_BE_NULL); Function> finder = sort -> { AbstractJPAQuery select = (AbstractJPAQuery) createQuery(predicate).select(path); @@ -188,9 +197,10 @@ public R findBy(Predicate predicate, Function { + ScrollQueryFactory> scroll = (q, scrollPosition) -> { Predicate predicateToUse = predicate; + Sort sort = q.sort; if (scrollPosition instanceof KeysetScrollPosition keyset) { @@ -214,7 +224,7 @@ public R findBy(Predicate predicate, Function> pagedFinder = (sort, pageable) -> { @@ -229,17 +239,24 @@ public R findBy(Predicate predicate, Function fluentQuery = new FetchableFluentQueryByPredicate<>( // - predicate, // - this.entityInformation.getJavaType(), // + path, predicate, // + this.entityInformation, // finder, // - new PredicateScrollDelegate<>(scroll, entityInformation), // + scroll, // pagedFinder, // this::count, // this::exists, // entityManager, // getProjectionFactory()); - return queryFunction.apply((FetchableFluentQuery) fluentQuery); + R result = queryFunction.apply((FetchableFluentQuery) fluentQuery); + + if (result instanceof FluentQuery) { + throw new InvalidDataAccessApiUsageException( + "findBy(…) queries must result a query result and not the FluentQuery object to ensure that queries are executed within the scope of the findBy(…) method"); + } + + return result; } @Override @@ -247,6 +264,33 @@ public long count(Predicate predicate) { return createQuery(predicate).fetchCount(); } + /** + * Delete entities by the given {@link Predicate} by loading and removing these. + *

      + * 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 results = (List) 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; @@ -260,7 +304,7 @@ public boolean exists(Predicate predicate) { */ protected AbstractJPAQuery createQuery(Predicate... predicate) { - Assert.notNull(predicate, "Predicate must not be null"); + Assert.notNull(predicate, PREDICATE_MUST_NOT_BE_NULL); AbstractJPAQuery query = doCreateQuery(getQueryHints().withFetchGraphs(entityManager), predicate); CrudMethodMetadata metadata = getRepositoryMethodMetadata(); @@ -282,8 +326,7 @@ protected JPQLQuery createCountQuery(@Nullable Predicate... predicate) { return doCreateQuery(getQueryHintsForCount(), predicate); } - @Nullable - private CrudMethodMetadata getRepositoryMethodMetadata() { + private @Nullable CrudMethodMetadata getRepositoryMethodMetadata() { return metadata; } @@ -360,13 +403,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 0f32999b14..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepository.java +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright 2008-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.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 extends SimpleJpaRepository - implements QuerydslPredicateExecutor { - - private final EntityPath path; - private final PathBuilder 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 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 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 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 findAll(Predicate predicate) { - return createQuery(predicate).select(path).fetch(); - } - - @Override - public List findAll(Predicate predicate, OrderSpecifier... orders) { - return executeSorted(createQuery(predicate).select(path), orders); - } - - @Override - public List findAll(Predicate predicate, Sort sort) { - - Assert.notNull(sort, "Sort must not be null"); - - return executeSorted(createQuery(predicate).select(path), sort); - } - - @Override - public List findAll(OrderSpecifier... orders) { - - Assert.notNull(orders, "Order specifiers must not be null"); - - return executeSorted(createQuery(new Predicate[0]).select(path), orders); - } - - @Override - public Page findAll(Predicate predicate, Pageable pageable) { - - Assert.notNull(pageable, "Pageable must not be null"); - - final JPQLQuery countQuery = createCountQuery(predicate); - JPQLQuery query = querydsl.applyPagination(pageable, createQuery(predicate).select(path)); - - return PageableExecutionUtils.getPage(query.fetch(), pageable, countQuery::fetchCount); - } - - @Override - public R findBy(Predicate predicate, - Function, 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 executeSorted(JPQLQuery 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 executeSorted(JPQLQuery 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 a140b734b0..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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -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 PathBuilder 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 50afeee8a5..619ccf14f8 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -19,55 +19,65 @@ 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.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.StreamSupport; +import org.jspecify.annotations.Nullable; + +import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Example; import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +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; +import org.springframework.data.jpa.repository.query.KeysetScrollDelegate; import org.springframework.data.jpa.repository.query.KeysetScrollSpecification; import org.springframework.data.jpa.repository.query.QueryUtils; import org.springframework.data.jpa.repository.support.FetchableFluentQueryBySpecification.SpecificationScrollDelegate; import org.springframework.data.jpa.repository.support.FluentQuerySupport.ScrollQueryFactory; import org.springframework.data.jpa.repository.support.QueryHints.NoHints; import org.springframework.data.jpa.support.PageableUtils; +import org.springframework.data.mapping.PropertyPath; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.query.FluentQuery; import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.data.util.Lazy; 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; @@ -93,19 +103,31 @@ * @author Yanming Zhou * @author Ernst-Jan van der Laan * @author Diego Krupitza + * @author Seol-JY + * @author Joshua Chen + * @author Giheon Do */ @Repository @Transactional(readOnly = true) public class SimpleJpaRepository implements JpaRepositoryImplementation { private static final String ID_MUST_NOT_BE_NULL = "The given id must not be null"; + private static final String IDS_MUST_NOT_BE_NULL = "Ids must not be null"; + private static final String ENTITY_MUST_NOT_BE_NULL = "Entity must not be null"; + private static final String ENTITIES_MUST_NOT_BE_NULL = "Entities must not be null"; + private static final String EXAMPLE_MUST_NOT_BE_NULL = "Example must not be null"; + private static final String SPECIFICATION_MUST_NOT_BE_NULL = "Specification must not be null"; + private static final String QUERY_FUNCTION_MUST_NOT_BE_NULL = "Query function must not be null"; private final JpaEntityInformation entityInformation; private final EntityManager entityManager; private final PersistenceProvider provider; + private final Lazy deleteAllQueryString; + private final Lazy countQueryString; + private @Nullable CrudMethodMetadata metadata; - private @Nullable ProjectionFactory projectionFactory; + private ProjectionFactory projectionFactory; private EscapeCharacter escapeCharacter = EscapeCharacter.DEFAULT; /** @@ -122,6 +144,13 @@ public SimpleJpaRepository(JpaEntityInformation entityInformation, EntityM this.entityInformation = entityInformation; this.entityManager = entityManager; this.provider = PersistenceProvider.fromEntityManager(entityManager); + this.projectionFactory = new SpelAwareProxyProjectionFactory(); + + this.deleteAllQueryString = Lazy + .of(() -> getQueryString(DELETE_ALL_QUERY_STRING, entityInformation.getEntityName())); + this.countQueryString = Lazy + .of(() -> getQueryString(String.format(COUNT_QUERY_STRING, provider.getCountQueryPlaceholder(), "%s"), + entityInformation.getEntityName())); } /** @@ -155,8 +184,7 @@ public void setProjectionFactory(ProjectionFactory projectionFactory) { this.projectionFactory = projectionFactory; } - @Nullable - protected CrudMethodMetadata getRepositoryMethodMetadata() { + protected @Nullable CrudMethodMetadata getRepositoryMethodMetadata() { return metadata; } @@ -164,18 +192,8 @@ protected Class getDomainClass() { return entityInformation.getJavaType(); } - private String getDeleteAllQueryString() { - return getQueryString(DELETE_ALL_QUERY_STRING, entityInformation.getEntityName()); - } - - private String getCountQueryString() { - - String countQuery = String.format(COUNT_QUERY_STRING, provider.getCountQueryPlaceholder(), "%s"); - return getQueryString(countQuery, entityInformation.getEntityName()); - } - - @Transactional @Override + @Transactional public void deleteById(ID id) { Assert.notNull(id, ID_MUST_NOT_BE_NULL); @@ -188,29 +206,40 @@ public void deleteById(ID id) { @SuppressWarnings("unchecked") public void delete(T entity) { - Assert.notNull(entity, "Entity must not be null"); + Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL); + + doDelete(entityManager, entityInformation, entity); + } + + static boolean doDelete(EntityManager entityManager, JpaEntityInformation entityInformation, T entity) { if (entityInformation.isNew(entity)) { - return; + return false; + } + + if (entityManager.contains(entity)) { + entityManager.remove(entity); + return true; } Class type = ProxyUtils.getUserClass(entity); + // if the entity to be deleted doesn't exist, delete is a NOOP T existing = (T) entityManager.find(type, entityInformation.getId(entity)); + if (existing != null) { + entityManager.remove(entityManager.merge(entity)); - // if the entity to be deleted doesn't exist, delete is a NOOP - if (existing == null) { - return; + return true; } - entityManager.remove(entityManager.contains(entity) ? entity : entityManager.merge(entity)); + return false; } @Override @Transactional public void deleteAllById(Iterable ids) { - Assert.notNull(ids, "Ids must not be null"); + Assert.notNull(ids, IDS_MUST_NOT_BE_NULL); for (ID id : ids) { deleteById(id); @@ -221,7 +250,7 @@ public void deleteAllById(Iterable ids) { @Transactional public void deleteAllByIdInBatch(Iterable ids) { - Assert.notNull(ids, "Ids must not be null"); + Assert.notNull(ids, IDS_MUST_NOT_BE_NULL); if (!ids.iterator().hasNext()) { return; @@ -236,21 +265,15 @@ public void deleteAllByIdInBatch(Iterable 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); /* * Some JPA providers require {@code ids} to be a {@link Collection} so we must convert if it's not already. */ - - if (Collection.class.isInstance(ids)) { - query.setParameter("ids", ids); - } else { - Collection idsCollection = StreamSupport.stream(ids.spliterator(), false) - .collect(Collectors.toCollection(ArrayList::new)); - query.setParameter("ids", idsCollection); - } + Collection idCollection = toCollection(ids); + query.setParameter("ids", idCollection); applyQueryHints(query); @@ -262,7 +285,7 @@ public void deleteAllByIdInBatch(Iterable ids) { @Transactional public void deleteAll(Iterable entities) { - Assert.notNull(entities, "Entities must not be null"); + Assert.notNull(entities, ENTITIES_MUST_NOT_BE_NULL); for (T entity : entities) { delete(entity); @@ -273,14 +296,13 @@ public void deleteAll(Iterable entities) { @Transactional public void deleteAllInBatch(Iterable entities) { - Assert.notNull(entities, "Entities must not be null"); + Assert.notNull(entities, ENTITIES_MUST_NOT_BE_NULL); if (!entities.iterator().hasNext()) { return; } - applyAndBind(getQueryString(DELETE_ALL_QUERY_STRING, entityInformation.getEntityName()), entities, - entityManager) + applyAndBind(getQueryString(DELETE_ALL_QUERY_STRING, entityInformation.getEntityName()), entities, entityManager) .executeUpdate(); } @@ -297,7 +319,7 @@ public void deleteAll() { @Transactional public void deleteAllInBatch() { - Query query = entityManager.createQuery(getDeleteAllQueryString()); + Query query = entityManager.createQuery(deleteAllQueryString.get()); applyQueryHints(query); @@ -318,7 +340,8 @@ public Optional findById(ID id) { LockModeType type = metadata.getLockModeType(); Map hints = getHints(); - return Optional.ofNullable(type == null ? entityManager.find(domainType, id, hints) : entityManager.find(domainType, id, type, hints)); + return Optional.ofNullable( + type == null ? entityManager.find(domainType, id, hints) : entityManager.find(domainType, id, type, hints)); } @Deprecated @@ -388,13 +411,13 @@ public boolean existsById(ID id) { @Override public List findAll() { - return getQuery(null, Sort.unsorted()).getResultList(); + return getQuery(Specification.unrestricted(), Sort.unsorted()).getResultList(); } @Override public List findAllById(Iterable ids) { - Assert.notNull(ids, "Ids must not be null"); + Assert.notNull(ids, IDS_MUST_NOT_BE_NULL); if (!ids.iterator().hasNext()) { return Collections.emptyList(); @@ -411,37 +434,31 @@ public List findAllById(Iterable ids) { return results; } - Collection idCollection = Streamable.of(ids).toList(); + Collection idCollection = toCollection(ids); + + TypedQuery query = getQuery((root, q, criteriaBuilder) -> { + + Path path = root.get(entityInformation.getIdAttribute()); + return path.in(idCollection); - ByIdsSpecification specification = new ByIdsSpecification<>(entityInformation); - TypedQuery query = getQuery(specification, Sort.unsorted()); + }, Sort.unsorted()); - return query.setParameter(specification.parameter, idCollection).getResultList(); + return query.getResultList(); } @Override public List findAll(Sort sort) { - return getQuery(null, sort).getResultList(); + return getQuery(Specification.unrestricted(), sort).getResultList(); } @Override public Page findAll(Pageable pageable) { - - if (pageable.isUnpaged()) { - return new PageImpl<>(findAll()); - } - - return findAll((Specification) null, pageable); + return findAll(Specification.unrestricted(), pageable); } @Override public Optional findOne(Specification 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 @@ -451,10 +468,15 @@ public List findAll(Specification spec) { @Override public Page findAll(Specification spec, Pageable pageable) { + return findAll(spec, spec, pageable); + } + + @Override + public Page findAll(@Nullable Specification spec, @Nullable Specification countSpec, Pageable pageable) { TypedQuery query = getQuery(spec, pageable); return pageable.isUnpaged() ? new PageImpl<>(query.getResultList()) - : readPage(query, getDomainClass(), pageable, spec); + : readPage(query, getDomainClass(), pageable, countSpec); } @Override @@ -465,6 +487,8 @@ public List findAll(Specification spec, Sort sort) { @Override public boolean exists(Specification spec) { + Assert.notNull(spec, "Specification must not be null"); + CriteriaQuery cq = this.entityManager.getCriteriaBuilder() // .createQuery(Integer.class) // .select(this.entityManager.getCriteriaBuilder().literal(1)); @@ -476,40 +500,44 @@ public boolean exists(Specification spec) { } @Override - public long delete(Specification spec) { + @Transactional + public long update(UpdateSpecification spec) { + + Assert.notNull(spec, "Specification must not be null"); - CriteriaBuilder builder = this.entityManager.getCriteriaBuilder(); - CriteriaDelete delete = builder.createCriteriaDelete(getDomainClass()); + return getUpdate(spec, getDomainClass()).executeUpdate(); + } - if (spec != null) { - Predicate predicate = spec.toPredicate(delete.from(getDomainClass()), null, builder); + @Override + @Transactional + public long delete(DeleteSpecification spec) { - if (predicate != null) { - delete.where(predicate); - } - } + Assert.notNull(spec, "Specification must not be null"); - return this.entityManager.createQuery(delete).executeUpdate(); + return getDelete(spec, getDomainClass()).executeUpdate(); } @Override - public R findBy(Specification spec, Function, R> queryFunction) { + public R findBy(Specification spec, + Function, R> queryFunction) { - Assert.notNull(spec, "Specification must not be null"); - Assert.notNull(queryFunction, "Query function must not be null"); + Assert.notNull(spec, SPECIFICATION_MUST_NOT_BE_NULL); + Assert.notNull(queryFunction, QUERY_FUNCTION_MUST_NOT_BE_NULL); return doFindBy(spec, getDomainClass(), queryFunction); } + @SuppressWarnings("unchecked") private R doFindBy(Specification spec, Class domainClass, - Function, R> queryFunction) { + Function, R> queryFunction) { - Assert.notNull(spec, "Specification must not be null"); - Assert.notNull(queryFunction, "Query function must not be null"); + Assert.notNull(spec, SPECIFICATION_MUST_NOT_BE_NULL); + Assert.notNull(queryFunction, QUERY_FUNCTION_MUST_NOT_BE_NULL); - ScrollQueryFactory scrollFunction = (sort, scrollPosition) -> { + ScrollQueryFactory> scrollFunction = (q, scrollPosition) -> { Specification specToUse = spec; + Sort sort = q.sort; if (scrollPosition instanceof KeysetScrollPosition keyset) { KeysetScrollSpecification keysetSpec = new KeysetScrollSpecification<>(keyset, sort, entityInformation); @@ -517,10 +545,10 @@ private R doFindBy(Specification spec, Class domainClass, specToUse = specToUse.and(keysetSpec); } - TypedQuery query = getQuery(specToUse, domainClass, sort); + TypedQuery query = getQuery(q.returnedType, specToUse, domainClass, sort, q.properties, scrollPosition); if (scrollPosition instanceof OffsetScrollPosition offset) { - if(!offset.isInitial()) { + if (!offset.isInitial()) { query.setFirstResult(Math.toIntExact(offset.getOffset()) + 1); } } @@ -528,26 +556,31 @@ private R doFindBy(Specification spec, Class domainClass, return query; }; - Function> finder = sort -> getQuery(spec, domainClass, sort); + Function, TypedQuery> finder = (q) -> getQuery(q.returnedType, spec, domainClass, + q.sort, q.properties, null); SpecificationScrollDelegate scrollDelegate = new SpecificationScrollDelegate<>(scrollFunction, entityInformation); - FetchableFluentQueryBySpecification fluentQuery = new FetchableFluentQueryBySpecification<>(spec, domainClass, finder, - scrollDelegate, this::count, this::exists, this.entityManager, getProjectionFactory()); + FetchableFluentQueryBySpecification fluentQuery = new FetchableFluentQueryBySpecification<>(spec, domainClass, + finder, scrollDelegate, this::count, this::exists, this.entityManager, getProjectionFactory()); + + R result = queryFunction.apply((SpecificationFluentQuery) fluentQuery); - return queryFunction.apply((FetchableFluentQuery) fluentQuery); + if (result instanceof FluentQuery) { + throw new InvalidDataAccessApiUsageException( + "findBy(…) queries must result a query result and not the FluentQuery object to ensure that queries are executed within the scope of the findBy(…) method"); + } + + return result; } @Override public Optional findOne(Example example) { - try { - return Optional - .of(getQuery(new ExampleSpecification<>(example, escapeCharacter), example.getProbeType(), Sort.unsorted()) - .setMaxResults(2).getSingleResult()); - } catch (NoResultException e) { - return Optional.empty(); - } + TypedQuery query = getQuery(new ExampleSpecification<>(example, escapeCharacter), example.getProbeType(), + Sort.unsorted()).setMaxResults(2); + + return Optional.ofNullable(query.getSingleResultOrNull()); } @Override @@ -592,10 +625,12 @@ public Page findAll(Example example, Pageable pageable) { } @Override - public R findBy(Example example, Function, R> queryFunction) { + @SuppressWarnings("unchecked") + public R findBy(Example example, + Function, R> queryFunction) { - Assert.notNull(example, "Sample must not be null"); - Assert.notNull(queryFunction, "Query function must not be null"); + Assert.notNull(example, EXAMPLE_MUST_NOT_BE_NULL); + Assert.notNull(queryFunction, QUERY_FUNCTION_MUST_NOT_BE_NULL); ExampleSpecification spec = new ExampleSpecification<>(example, escapeCharacter); Class probeType = example.getProbeType(); @@ -606,7 +641,7 @@ public R findBy(Example example, Function query = entityManager.createQuery(getCountQueryString(), Long.class); + TypedQuery query = entityManager.createQuery(countQueryString.get(), Long.class); applyQueryHintsForCount(query); @@ -614,15 +649,15 @@ public long count() { } @Override - public long count(@Nullable Specification spec) { + public long count(Specification spec) { return executeCountQuery(getCountQuery(spec, getDomainClass())); } - @Transactional @Override + @Transactional public S save(S entity) { - Assert.notNull(entity, "Entity must not be null"); + Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL); if (entityInformation.isNew(entity)) { entityManager.persist(entity); @@ -632,8 +667,8 @@ public S save(S entity) { } } - @Transactional @Override + @Transactional public S saveAndFlush(S entity) { S result = save(entity); @@ -642,11 +677,11 @@ public S saveAndFlush(S entity) { return result; } - @Transactional @Override + @Transactional public List saveAll(Iterable entities) { - Assert.notNull(entities, "Entities must not be null"); + Assert.notNull(entities, ENTITIES_MUST_NOT_BE_NULL); List result = new ArrayList<>(); @@ -657,8 +692,8 @@ public List saveAll(Iterable entities) { return result; } - @Transactional @Override + @Transactional public List saveAllAndFlush(Iterable entities) { List result = saveAll(entities); @@ -667,8 +702,8 @@ public List saveAllAndFlush(Iterable entities) { return result; } - @Transactional @Override + @Transactional public void flush() { entityManager.flush(); } @@ -683,7 +718,7 @@ public void flush() { * @deprecated use {@link #readPage(TypedQuery, Class, Pageable, Specification)} instead */ @Deprecated - protected Page readPage(TypedQuery query, Pageable pageable, @Nullable Specification spec) { + protected Page readPage(TypedQuery query, Pageable pageable, Specification spec) { return readPage(query, getDomainClass(), pageable, spec); } @@ -693,12 +728,15 @@ protected Page readPage(TypedQuery 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}. */ - protected Page readPage(TypedQuery query, final Class domainClass, Pageable pageable, + @Contract("_, _, _, null -> fail") + protected Page readPage(TypedQuery query, Class domainClass, Pageable pageable, @Nullable Specification spec) { + Assert.notNull(spec, "Specification must not be null"); + if (pageable.isPaged()) { query.setFirstResult(PageableUtils.getOffsetAsInteger(pageable)); query.setMaxResults(pageable.getPageSize()); @@ -711,53 +749,113 @@ protected Page readPage(TypedQuery query, final Class dom /** * 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 getQuery(@Nullable Specification spec, Pageable pageable) { - - Sort sort = pageable.isPaged() ? pageable.getSort() : Sort.unsorted(); - return getQuery(spec, getDomainClass(), sort); + return getQuery(spec, getDomainClass(), pageable.getSort()); } /** * 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 TypedQuery getQuery(@Nullable Specification spec, Class domainClass, - Pageable pageable) { - - Sort sort = pageable.isPaged() ? pageable.getSort() : Sort.unsorted(); - return getQuery(spec, domainClass, sort); + protected TypedQuery getQuery(Specification spec, Class 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 getQuery(@Nullable Specification spec, Sort sort) { + protected TypedQuery getQuery(Specification 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}. */ protected TypedQuery getQuery(@Nullable Specification spec, Class domainClass, Sort sort) { + return getQuery(ReturnedType.of(domainClass, domainClass, projectionFactory), spec, domainClass, sort, + Collections.emptySet(), null); + } + + /** + * Creates a {@link TypedQuery} for the given {@link Specification} and {@link Sort}. + * + * @param returnedType must not be {@literal null}. + * @param spec can be {@literal null}. + * @param domainClass must not be {@literal null}. + * @param sort must not be {@literal null}. + * @param inputProperties must not be {@literal null}. + * @param scrollPosition must not be {@literal null}. + */ + private TypedQuery getQuery(ReturnedType returnedType, @Nullable Specification spec, + Class domainClass, Sort sort, Collection inputProperties, @Nullable ScrollPosition scrollPosition) { + + Assert.notNull(spec, "Specification must not be null"); CriteriaBuilder builder = entityManager.getCriteriaBuilder(); - CriteriaQuery query = builder.createQuery(domainClass); + CriteriaQuery query; + + boolean interfaceProjection = returnedType.getReturnedType().isInterface(); + + if (returnedType.needsCustomConstruction() && (inputProperties.isEmpty() || !interfaceProjection)) { + inputProperties = returnedType.getInputProperties(); + } + + if (returnedType.needsCustomConstruction()) { + query = (CriteriaQuery) (interfaceProjection ? builder.createTupleQuery() + : builder.createQuery(returnedType.getReturnedType())); + } else { + query = builder.createQuery(domainClass); + } Root root = applySpecificationToCriteria(spec, domainClass, query); - query.select(root); + + if (returnedType.needsCustomConstruction()) { + + Collection requiredSelection; + + if (scrollPosition instanceof KeysetScrollPosition && interfaceProjection) { + requiredSelection = KeysetScrollDelegate.getProjectionInputProperties(entityInformation, inputProperties, sort); + } else { + requiredSelection = inputProperties; + } + + List> selections = new ArrayList<>(); + Set topLevelProperties = new HashSet<>(); + for (String property : requiredSelection) { + + int separator = property.indexOf('.'); + String topLevelProperty = separator == -1 ? property : property.substring(0, separator); + + if (!topLevelProperties.add(topLevelProperty)) { + continue; + } + + PropertyPath path = PropertyPath.from(topLevelProperty, returnedType.getDomainType()); + selections.add(QueryUtils.toExpressionRecursively(root, path, true).alias(topLevelProperty)); + } + + Class typeToRead = returnedType.getReturnedType(); + + query = typeToRead.isInterface() // + ? query.multiselect(selections) // + : query.select((Selection) builder.construct(typeToRead, // + selections.toArray(new Selection[0]))); + } else { + query.select(root); + } if (sort.isSorted()) { query.orderBy(toOrders(sort, root, builder)); @@ -766,24 +864,62 @@ protected TypedQuery getQuery(@Nullable Specification spec, 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 Query getUpdate(UpdateSpecification spec, Class domainClass) { + + Assert.notNull(spec, "Specification must not be null"); + + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + CriteriaUpdate 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 Query getDelete(DeleteSpecification spec, Class domainClass) { + + Assert.notNull(spec, "Specification must not be null"); + + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + CriteriaDelete 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 getCountQuery(@Nullable Specification spec) { + protected TypedQuery getCountQuery(Specification 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 TypedQuery getCountQuery(@Nullable Specification spec, Class domainClass) { + protected TypedQuery getCountQuery(Specification spec, Class domainClass) { + + Assert.notNull(spec, "Specification must not be null"); CriteriaBuilder builder = entityManager.getCriteriaBuilder(); CriteriaQuery query = builder.createQuery(Long.class); @@ -817,33 +953,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 Root applySpecificationToCriteria(@Nullable Specification spec, Class domainClass, + private Root applySpecificationToCriteria(Specification spec, Class domainClass, CriteriaQuery query) { - Assert.notNull(domainClass, "Domain class must not be null"); - Assert.notNull(query, "CriteriaQuery must not be null"); - Root 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 void applySpecificationToCriteria(UpdateSpecification spec, Class domainClass, + CriteriaUpdate query) { + + Root root = query.from(domainClass); + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); Predicate predicate = spec.toPredicate(root, query, builder); if (predicate != null) { query.where(predicate); } + } - return root; + private void applySpecificationToCriteria(DeleteSpecification spec, Class domainClass, + CriteriaDelete query) { + + Root root = query.from(domainClass); + + CriteriaBuilder builder = entityManager.getCriteriaBuilder(); + Predicate predicate = spec.toPredicate(root, query, builder); + + if (predicate != null) { + query.where(predicate); + } } private TypedQuery applyRepositoryMethodMetadata(TypedQuery query) { @@ -860,6 +1008,20 @@ private TypedQuery applyRepositoryMethodMetadata(TypedQuery 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) { @@ -907,7 +1069,7 @@ private Map getHints() { private void applyComment(CrudMethodMetadata metadata, BiConsumer 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())); } } @@ -920,6 +1082,11 @@ private ProjectionFactory getProjectionFactory() { return projectionFactory; } + @SuppressWarnings({ "rawtypes", "unchecked" }) + private static Collection toCollection(Iterable ids) { + return ids instanceof Collection c ? c : Streamable.of(ids).toList(); + } + /** * Executes a count query and transparently sums up all values returned. * @@ -939,36 +1106,6 @@ private static long executeCountQuery(TypedQuery 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 OPENJPA-2018 - */ - @SuppressWarnings("rawtypes") - private static final class ByIdsSpecification implements Specification { - - private static final long serialVersionUID = 1L; - - private final JpaEntityInformation entityInformation; - - @Nullable ParameterExpression> parameter; - - ByIdsSpecification(JpaEntityInformation entityInformation) { - this.entityInformation = entityInformation; - } - - @Override - public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuilder cb) { - - Path path = root.get(entityInformation.getIdAttribute()); - parameter = (ParameterExpression>) (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}. @@ -977,12 +1114,8 @@ public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuild * @author Christoph Strobl * @since 1.10 */ - private static class ExampleSpecification implements Specification { - - private static final long serialVersionUID = 1L; - - private final Example example; - private final EscapeCharacter escapeCharacter; + private record ExampleSpecification(Example example, + EscapeCharacter escapeCharacter) implements Specification { /** * Creates new {@link ExampleSpecification}. @@ -990,17 +1123,15 @@ private static class ExampleSpecification implements Specification { * @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 example, EscapeCharacter escapeCharacter) { + private ExampleSpecification { - Assert.notNull(example, "Example must not be null"); + 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 root, CriteriaQuery query, CriteriaBuilder cb) { + public @Nullable Predicate toPredicate(Root 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 new file mode 100644 index 0000000000..d5d518d004 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java @@ -0,0 +1,101 @@ +/* + * 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 jakarta.persistence.EntityManager; +import jakarta.persistence.Query; +import jakarta.persistence.Tuple; + +import java.util.Collection; +import java.util.Map; + +import com.querydsl.core.QueryModifiers; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.FactoryExpression; +import com.querydsl.jpa.JPQLSerializer; +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 + * {@code EntityManager#createQuery(queryString, Tuple.class)}. + * + * @author Mark Paluch + * @since 3.5 + */ +class SpringDataJpaQuery extends JPAQuery { + + public SpringDataJpaQuery(EntityManager em) { + super(em); + } + + public SpringDataJpaQuery(EntityManager em, JPQLTemplates templates) { + super(em, templates); + } + + protected Query createQuery(@Nullable QueryModifiers modifiers, boolean forCount) { + + JPQLSerializer serializer = serialize(forCount); + String queryString = serializer.toString(); + logQuery(queryString); + + Query query = getMetadata().getProjection() instanceof JakartaTuple + ? entityManager.createQuery(queryString, Tuple.class) + : entityManager.createQuery(queryString); + + JPAUtil.setConstants(query, serializer.getConstants(), getMetadata().getParams()); + if (modifiers != null && modifiers.isRestricting()) { + Integer limit = modifiers.getLimitAsInteger(); + Integer offset = modifiers.getOffsetAsInteger(); + if (limit != null) { + query.setMaxResults(limit); + } + if (offset != null) { + query.setFirstResult(offset); + } + } + if (lockMode != null) { + query.setLockMode(lockMode); + } + if (flushMode != null) { + query.setFlushMode(flushMode); + } + + for (Map.Entry entry : hints.entrySet()) { + + if (entry.getValue() instanceof Collection c) { + c.forEach((value) -> query.setHint(entry.getKey(), value)); + } else { + query.setHint(entry.getKey(), entry.getValue()); + } + } + + // set transformer, if necessary and possible + Expression projection = getMetadata().getProjection(); + this.projection = null; // necessary when query is reused + + if (!forCount && projection instanceof FactoryExpression) { + if (!queryHandler.transform(query, (FactoryExpression) projection)) { + this.projection = (FactoryExpression) projection; + } + } + + return query; + } + +} 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 8415d67959..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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -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 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/MergingPersistenceUnitManager.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/MergingPersistenceUnitManager.java index e2703107cc..6ab0bb64d1 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/MergingPersistenceUnitManager.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/MergingPersistenceUnitManager.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -18,18 +18,18 @@ import java.net.URISyntaxException; import java.net.URL; -import jakarta.persistence.spi.PersistenceUnitInfo; - import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.orm.jpa.persistenceunit.DefaultPersistenceUnitManager; import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo; +import org.springframework.util.StringUtils; /** * Extends {@link DefaultPersistenceUnitManager} to merge configurations of one persistence unit residing in multiple * {@code persistence.xml} files into one. This is necessary to allow the declaration of entities in separate modules. * * @author Oliver Gierke + * @author Christoph Strobl * @link https://github.com/spring-projects/spring-framework/issues/7287 */ public class MergingPersistenceUnitManager extends DefaultPersistenceUnitManager { @@ -42,10 +42,12 @@ protected void postProcessPersistenceUnitInfo(MutablePersistenceUnitInfo pui) { // Invoke normal post processing super.postProcessPersistenceUnitInfo(pui); - PersistenceUnitInfo oldPui = getPersistenceUnitInfo(((PersistenceUnitInfo) pui).getPersistenceUnitName()); + if (StringUtils.hasText(pui.getPersistenceUnitName())) { + MutablePersistenceUnitInfo oldPui = getPersistenceUnitInfo(pui.getPersistenceUnitName()); - if (oldPui != null) { - postProcessPersistenceUnitInfo(pui, oldPui); + if (oldPui != null) { + postProcessPersistenceUnitInfo(pui, oldPui); + } } } @@ -54,7 +56,7 @@ protected boolean isPersistenceUnitOverrideAllowed() { return true; } - void postProcessPersistenceUnitInfo(MutablePersistenceUnitInfo pui, PersistenceUnitInfo oldPui) { + void postProcessPersistenceUnitInfo(MutablePersistenceUnitInfo pui, MutablePersistenceUnitInfo oldPui) { String persistenceUnitName = pui.getPersistenceUnitName(); @@ -82,7 +84,8 @@ void postProcessPersistenceUnitInfo(MutablePersistenceUnitInfo pui, PersistenceU if (!pui.getMappingFileNames().contains(mappingFileName)) { if (LOG.isDebugEnabled()) { - LOG.debug(String.format("Adding mapping file %s to persistence unit %s", mappingFileName, persistenceUnitName)); + LOG.debug( + String.format("Adding mapping file %s to persistence unit %s", mappingFileName, persistenceUnitName)); } pui.addMappingFileName(mappingFileName); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/PageableUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/PageableUtils.java index c50a8f98aa..fab11c139a 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/PageableUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/PageableUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. 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/BeanDefinitionUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/BeanDefinitionUtils.java index 0357fefa86..3216abef21 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/BeanDefinitionUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/BeanDefinitionUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -26,6 +26,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.function.BiPredicate; import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.ListableBeanFactory; @@ -96,20 +97,37 @@ public static Iterable getEntityManagerFactoryBeanNames(ListableBeanFact */ public static Collection getEntityManagerFactoryBeanDefinitions( ConfigurableListableBeanFactory beanFactory) { + return getEntityManagerFactoryBeanDefinitions(beanFactory, (beanName, beanDefinition) -> true); + } + + /** + * Returns {@link EntityManagerFactoryBeanDefinition} instances for all {@link BeanDefinition} registered in the given + * {@link ConfigurableListableBeanFactory} hierarchy. + * + * @param beanFactory must not be {@literal null}. + * @param beanDefinitionBiPredicate predicate to determine whether a {@link EntityManagerFactory} bean should be + * decorated with a {@code SharedEntityManager} bean definition. + * @return + * @since 4.0 + */ + public static Collection getEntityManagerFactoryBeanDefinitions( + ConfigurableListableBeanFactory beanFactory, BiPredicate beanDefinitionBiPredicate) { Set definitions = new HashSet<>(); for (Class type : EMF_TYPES) { for (String name : beanFactory.getBeanNamesForType(type, true, false)) { - registerEntityManagerFactoryBeanDefinition(transformedBeanName(name), beanFactory, definitions); + registerEntityManagerFactoryBeanDefinition(transformedBeanName(name), beanFactory, definitions, + beanDefinitionBiPredicate); } } BeanFactory parentBeanFactory = beanFactory.getParentBeanFactory(); if (parentBeanFactory instanceof ConfigurableListableBeanFactory parentConfigurableListableBeanFactory) { - definitions.addAll(getEntityManagerFactoryBeanDefinitions(parentConfigurableListableBeanFactory)); + definitions.addAll( + getEntityManagerFactoryBeanDefinitions(parentConfigurableListableBeanFactory, beanDefinitionBiPredicate)); } return definitions; @@ -122,9 +140,11 @@ public static Collection getEntityManagerFac * @param name * @param beanFactory * @param definitions + * @param decoratorPredicate */ private static void registerEntityManagerFactoryBeanDefinition(String name, - ConfigurableListableBeanFactory beanFactory, Collection definitions) { + ConfigurableListableBeanFactory beanFactory, Collection definitions, + BiPredicate decoratorPredicate) { BeanDefinition definition = beanFactory.getBeanDefinition(name); @@ -139,7 +159,9 @@ private static void registerEntityManagerFactoryBeanDefinition(String name, return; } - definitions.add(new EntityManagerFactoryBeanDefinition(name, beanFactory)); + if (decoratorPredicate.test(name, definition)) { + definitions.add(new EntityManagerFactoryBeanDefinition(name, beanFactory)); + } } /** @@ -223,12 +245,10 @@ public boolean equals(Object o) { return true; } - if (!(o instanceof EntityManagerFactoryBeanDefinition)) { + if (!(o instanceof EntityManagerFactoryBeanDefinition that)) { return false; } - EntityManagerFactoryBeanDefinition that = (EntityManagerFactoryBeanDefinition) o; - if (!ObjectUtils.nullSafeEquals(beanName, that.beanName)) { return false; } 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 6969cfb4a5..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 @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. @@ -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/JpaMetamodel.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/JpaMetamodel.java index c822dce291..fc2cb71ae2 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/JpaMetamodel.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/JpaMetamodel.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. @@ -17,6 +17,7 @@ import java.util.Collection; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -38,6 +39,7 @@ * @author Oliver Gierke * @author Mark Paluch * @author Sylvère Richard + * @author Aref Behboodi */ public class JpaMetamodel { @@ -45,8 +47,8 @@ public class JpaMetamodel { private final Metamodel metamodel; - private Lazy>> managedTypes; - private Lazy>> jpaEmbeddables; + private final Lazy>> managedTypes; + private final Lazy>> jpaEmbeddables; /** * Creates a new {@link JpaMetamodel} for the given JPA {@link Metamodel}. @@ -61,12 +63,12 @@ private JpaMetamodel(Metamodel metamodel) { this.managedTypes = Lazy.of(() -> metamodel.getManagedTypes().stream() // .map(ManagedType::getJavaType) // - .filter(it -> it != null) // + .filter(Objects::nonNull) // .collect(StreamUtils.toUnmodifiableSet())); this.jpaEmbeddables = Lazy.of(() -> metamodel.getEmbeddables().stream() // .map(ManagedType::getJavaType) - .filter(it -> it != null) + .filter(Objects::nonNull) .filter(it -> AnnotatedElementUtils.isAnnotated(it, Embeddable.class)) .collect(StreamUtils.toUnmodifiableSet())); } @@ -101,7 +103,7 @@ public boolean isSingleIdAttribute(Class entity, String name, Class attrib return metamodel.getEntities().stream() // .filter(it -> entity.equals(it.getJavaType())) // .findFirst() // - .flatMap(it -> getSingularIdAttribute(it)) // + .flatMap(JpaMetamodel::getSingularIdAttribute) // .filter(it -> it.getJavaType().equals(attributeType)) // .map(it -> it.getName().equals(name)) // .orElse(false); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/JpaMetamodelCacheCleanup.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/JpaMetamodelCacheCleanup.java index ed83f5d185..c9c3e70447 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/JpaMetamodelCacheCleanup.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/JpaMetamodelCacheCleanup.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. @@ -30,7 +30,7 @@ class JpaMetamodelCacheCleanup implements DisposableBean { @Override - public void destroy() throws Exception { + public void destroy() { JpaMetamodel.clear(); } } 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 { + + 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 m) { + throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); + } + + @Override + public Set keySet() { + + return tuple.getElements().stream() // + .map(TupleElement::getAlias) // + .collect(Collectors.toSet()); + } + + @Override + public Collection values() { + return Arrays.asList(tuple.toArray()); + } + + @Override + public Set> entrySet() { + + return tuple.getElements().stream() // + .map(e -> new HashMap.SimpleEntry(e.getAlias(), tuple.get(e))) // + .collect(Collectors.toSet()); + } + + static class FallbackTupleWrapper implements Tuple { + + private final Tuple delegate; + private final UnaryOperator fallbackNameTransformer = JdbcUtils::convertPropertyNameToUnderscoreName; + + FallbackTupleWrapper(Tuple delegate) { + this.delegate = delegate; + } + + @Override + public X get(TupleElement tupleElement) { + return get(tupleElement.getAlias(), tupleElement.getJavaType()); + } + + @Override + public X get(String s, Class 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 get(int i, Class 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> 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/main/resources/META-INF/spring/aot.factories b/spring-data-jpa/src/main/resources/META-INF/spring/aot.factories index 50d5fc795e..f4ea3321d5 100644 --- a/spring-data-jpa/src/main/resources/META-INF/spring/aot.factories +++ b/spring-data-jpa/src/main/resources/META-INF/spring/aot.factories @@ -1,2 +1,5 @@ org.springframework.aot.hint.RuntimeHintsRegistrar=\ org.springframework.data.jpa.repository.aot.JpaRuntimeHints + +org.springframework.data.util.TypeCollector$TypeCollectorFilters=\ + org.springframework.data.jpa.repository.aot.JpaTypeFilters 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 bc3c34dd34..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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -22,13 +22,14 @@ 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; import org.springframework.asm.ClassVisitor; import org.springframework.asm.MethodVisitor; import org.springframework.asm.Opcodes; -import org.springframework.data.jpa.util.DisabledOnHibernate62; -import org.springframework.lang.Nullable; +import org.springframework.data.jpa.util.DisabledOnHibernate; /** * Test to verify that we use the same Antlr version as Hibernate. We parse {@code org.hibernate.grammars.hql.HqlParser} @@ -41,7 +42,7 @@ class AntlrVersionTests { @Test - @DisabledOnHibernate62 + @DisabledOnHibernate("6.2") void antlrVersionConvergence() throws Exception { ClassReader reader = new ClassReader(HqlParser.class.getName()); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilderUnitTests.java index 0cd7169d04..544644ef81 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilderUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilderUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. @@ -16,6 +16,7 @@ package org.springframework.data.jpa.convert; import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import static org.springframework.data.domain.Example.*; @@ -23,6 +24,7 @@ import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.Expression; import jakarta.persistence.criteria.Join; +import jakarta.persistence.criteria.JoinType; import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; @@ -39,6 +41,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -47,6 +51,7 @@ import org.springframework.data.domain.Example; import org.springframework.data.domain.ExampleMatcher; import org.springframework.data.domain.ExampleMatcher.GenericPropertyMatcher; +import org.springframework.data.domain.ExampleMatcher.MatchMode; import org.springframework.data.jpa.repository.query.EscapeCharacter; import org.springframework.util.ObjectUtils; @@ -57,6 +62,7 @@ * @author Mark Paluch * @author Oliver Gierke * @author Jens Schauder + * @author Arnaud Lecointre */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -271,6 +277,21 @@ void likePatternsGetEscapedEnding() { verify(cb, times(1)).like(any(Expression.class), eq("%f\\\\o\\_o"), eq('\\')); } + @ParameterizedTest(name = "Matching {0} on association should join using JoinType.{1} ") // GH-3763 + @CsvSource({ "ALL, INNER", "ANY, LEFT" }) + void matchingAssociationShouldUseTheCorrectJoinType(MatchMode matchMode, JoinType expectedJoinType) { + + Person person = new Person(); + person.father = new Person(); + + ExampleMatcher matcher = matchMode == MatchMode.ALL ? ExampleMatcher.matchingAll() : ExampleMatcher.matchingAny(); + Example example = of(person, matcher); + + QueryByExamplePredicateBuilder.getPredicate(root, cb, example, EscapeCharacter.DEFAULT); + + verify(root, times(1)).join("father", expectedJoinType); + } + @SuppressWarnings("unused") static class Person { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/DateTimeSample.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/DateTimeSample.java index 7743c9868c..579c430b83 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/DateTimeSample.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/DateTimeSample.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersIntegrationTests.java index 5ef388138e..ff76fbd718 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersUnitTests.java index 92a31cec4a..691d1a83d9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -17,10 +17,10 @@ import static org.assertj.core.api.Assertions.*; -import java.util.Arrays; - import jakarta.persistence.AttributeConverter; +import java.util.Arrays; + import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; @@ -31,7 +31,7 @@ */ class Jsr310JpaConvertersUnitTests { - static Iterable data() { + static Iterable data() { return Arrays.asList(new Jsr310JpaConverters.InstantConverter(), // new Jsr310JpaConverters.LocalDateConverter(), // @@ -40,7 +40,6 @@ static Iterable data() { new Jsr310JpaConverters.ZoneIdConverter()); } - @ParameterizedTest @MethodSource("data") void convertersHandleNullValuesCorrectly(AttributeConverter converter) { 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..13e051c46f --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/DeleteSpecificationUnitTests.java @@ -0,0 +1,178 @@ +/* + * 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.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 + * @author Peter Aisher + */ +@SuppressWarnings({ "unchecked", "deprecation" }) +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class DeleteSpecificationUnitTests implements Serializable { + + private DeleteSpecification spec; + @Mock(serializable = true) Root root; + @Mock(serializable = true) CriteriaDelete 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 specification = DeleteSpecification.unrestricted(); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, delete, builder)).isNull(); + } + + @Test // GH-3521 + void allOfCombinesPredicatesInOrder() { + + DeleteSpecification specification = DeleteSpecification.allOf(spec); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, delete, builder)).isSameAs(predicate); + } + + @Test // GH-3521 + void anyOfCombinesPredicatesInOrder() { + + DeleteSpecification specification = DeleteSpecification.allOf(spec); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, delete, builder)).isSameAs(predicate); + } + + @Test // GH-3521 + void emptyAllOfReturnsEmptySpecification() { + + DeleteSpecification specification = DeleteSpecification.allOf(); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, delete, builder)).isNull(); + } + + @Test // GH-3521 + void emptyAnyOfReturnsEmptySpecification() { + + DeleteSpecification specification = DeleteSpecification.anyOf(); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, delete, builder)).isNull(); + } + + @Test // GH-3521 + void specificationsShouldBeSerializable() { + + DeleteSpecification serializableSpec = new SerializableSpecification(); + DeleteSpecification specification = serializableSpec.and(serializableSpec); + + assertThat(specification).isNotNull(); + + DeleteSpecification transferredSpecification = (DeleteSpecification) deserialize( + serialize(specification)); + + assertThat(transferredSpecification).isNotNull(); + } + + @Test // GH-3521 + void complexSpecificationsShouldBeSerializable() { + + SerializableSpecification serializableSpec = new SerializableSpecification(); + DeleteSpecification specification = DeleteSpecification + .not(serializableSpec.and(serializableSpec).or(serializableSpec)); + + assertThat(specification).isNotNull(); + + DeleteSpecification transferredSpecification = (DeleteSpecification) deserialize( + serialize(specification)); + + assertThat(transferredSpecification).isNotNull(); + } + + @Test // GH-3521 + void andCombinesSpecificationsInOrder() { + + Predicate firstPredicate = mock(Predicate.class); + Predicate secondPredicate = mock(Predicate.class); + + DeleteSpecification first = ((root1, delete, criteriaBuilder) -> firstPredicate); + DeleteSpecification 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 first = ((root1, delete, criteriaBuilder) -> firstPredicate); + DeleteSpecification second = ((root1, delete, criteriaBuilder) -> secondPredicate); + + first.or(second).toPredicate(root, delete, builder); + + verify(builder).or(firstPredicate, secondPredicate); + } + + @Test // GH-3849, GH-4023 + void notWithNullPredicate() { + + DeleteSpecification notSpec = DeleteSpecification.not((r, q, cb) -> null); + + assertThat(notSpec.toPredicate(root, delete, builder)).isNull(); + verifyNoInteractions(builder); + } + + static class SerializableSpecification implements Serializable, DeleteSpecification { + + @Override + public Predicate toPredicate(Root root, CriteriaDelete 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 53d3d0df21..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -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..797aaaea2f --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/PredicateSpecificationUnitTests.java @@ -0,0 +1,177 @@ +/* + * 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.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.From; +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 + * @author Peter Aisher + */ +@SuppressWarnings({ "unchecked", "deprecation" }) +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class PredicateSpecificationUnitTests implements Serializable { + + private PredicateSpecification spec; + @Mock(serializable = true) Root 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 specification = PredicateSpecification.unrestricted(); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, builder)).isNull(); + } + + @Test // GH-3521 + void allOfCombinesPredicatesInOrder() { + + PredicateSpecification specification = PredicateSpecification.allOf(spec); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, builder)).isSameAs(predicate); + } + + @Test // GH-3521 + void anyOfCombinesPredicatesInOrder() { + + PredicateSpecification specification = PredicateSpecification.allOf(spec); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, builder)).isSameAs(predicate); + } + + @Test // GH-3521 + void emptyAllOfReturnsEmptySpecification() { + + PredicateSpecification specification = PredicateSpecification.allOf(); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, builder)).isNull(); + } + + @Test // GH-3521 + void emptyAnyOfReturnsEmptySpecification() { + + PredicateSpecification specification = PredicateSpecification.anyOf(); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, builder)).isNull(); + } + + @Test // GH-3521 + void specificationsShouldBeSerializable() { + + PredicateSpecification serializableSpec = new SerializableSpecification(); + PredicateSpecification specification = serializableSpec.and(serializableSpec); + + assertThat(specification).isNotNull(); + + PredicateSpecification transferredSpecification = (PredicateSpecification) deserialize( + serialize(specification)); + + assertThat(transferredSpecification).isNotNull(); + } + + @Test // GH-3521 + void complexSpecificationsShouldBeSerializable() { + + SerializableSpecification serializableSpec = new SerializableSpecification(); + PredicateSpecification specification = PredicateSpecification + .not(serializableSpec.and(serializableSpec).or(serializableSpec)); + + assertThat(specification).isNotNull(); + + PredicateSpecification transferredSpecification = (PredicateSpecification) deserialize( + serialize(specification)); + + assertThat(transferredSpecification).isNotNull(); + } + + @Test // GH-3521 + void andCombinesSpecificationsInOrder() { + + Predicate firstPredicate = mock(Predicate.class); + Predicate secondPredicate = mock(Predicate.class); + + PredicateSpecification first = ((root1, criteriaBuilder) -> firstPredicate); + PredicateSpecification 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 first = ((root1, criteriaBuilder) -> firstPredicate); + PredicateSpecification second = ((root1, criteriaBuilder) -> secondPredicate); + + first.or(second).toPredicate(root, builder); + + verify(builder).or(firstPredicate, secondPredicate); + } + + @Test // GH-3849, GH-4023 + void notWithNullPredicate() { + + PredicateSpecification notSpec = PredicateSpecification.not((r, cb) -> null); + + assertThat(notSpec.toPredicate(root, builder)).isNull(); + verifyNoInteractions(builder); + } + + static class SerializableSpecification implements Serializable, PredicateSpecification { + + @Override + public Predicate toPredicate(From 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 aeb0c819ea..4082596884 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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -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; @@ -28,7 +26,6 @@ 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; @@ -45,100 +42,21 @@ * @author Jens Schauder * @author Mark Paluch * @author Daniel Shuy + * @author Heeeun Cho + * @author Peter Aisher */ -@SuppressWarnings("serial") +@SuppressWarnings({ "unchecked", "deprecation" }) @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -class SpecificationUnitTests implements Serializable { +class SpecificationUnitTests { - private Specification spec; @Mock(serializable = true) Root root; @Mock(serializable = true) CriteriaQuery query; @Mock(serializable = true) CriteriaBuilder builder; - @Mock(serializable = true) Predicate predicate; - @BeforeEach - void setUp() { - - spec = (root, query, cb) -> predicate; - } - - @Test // DATAJPA-300, DATAJPA-1170 - void createsSpecificationsFromNull() { - - Specification specification = where(null); - assertThat(specification).isNotNull(); - assertThat(specification.toPredicate(root, query, builder)).isNull(); - } - - @Test // DATAJPA-300, DATAJPA-1170 - void negatesNullSpecToNull() { - - Specification specification = not(null); - - assertThat(specification).isNotNull(); - assertThat(specification.toPredicate(root, query, builder)).isNull(); - } - - @Test // DATAJPA-300, DATAJPA-1170 - void andConcatenatesSpecToNullSpec() { - - Specification 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 specification = spec.and(null); - - assertThat(specification).isNotNull(); - assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate); - } - - @Test // DATAJPA-300, DATAJPA-1170 - void orConcatenatesSpecToNullSpec() { - - Specification 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 specification = spec.or(null); - - assertThat(specification).isNotNull(); - assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate); - } - - @Test // GH-1943 - public void allOfConcatenatesNull() { - - Specification specification = Specification.allOf(null, spec, null); - - assertThat(specification).isNotNull(); - assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate); - } - - @Test // GH-1943 - public void anyOfConcatenatesNull() { - - Specification specification = Specification.anyOf(null, spec, null); - - assertThat(specification).isNotNull(); - assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate); - } - @Test // GH-1943 - public void emptyAllOfReturnsEmptySpecification() { + void emptyAllOfReturnsEmptySpecification() { Specification specification = Specification.allOf(); @@ -147,7 +65,7 @@ public void emptyAllOfReturnsEmptySpecification() { } @Test // GH-1943 - public void emptyAnyOfReturnsEmptySpecification() { + void emptyAnyOfReturnsEmptySpecification() { Specification specification = Specification.anyOf(); @@ -163,7 +81,6 @@ void specificationsShouldBeSerializable() { assertThat(specification).isNotNull(); - @SuppressWarnings("unchecked") Specification transferredSpecification = (Specification) deserialize(serialize(specification)); assertThat(transferredSpecification).isNotNull(); @@ -178,7 +95,6 @@ void complexSpecificationsShouldBeSerializable() { assertThat(specification).isNotNull(); - @SuppressWarnings("unchecked") Specification transferredSpecification = (Specification) deserialize(serialize(specification)); assertThat(transferredSpecification).isNotNull(); @@ -191,7 +107,6 @@ void andCombinesSpecificationsInOrder() { Predicate secondPredicate = mock(Predicate.class); Specification first = ((root1, query1, criteriaBuilder) -> firstPredicate); - Specification second = ((root1, query1, criteriaBuilder) -> secondPredicate); first.and(second).toPredicate(root, query, builder); @@ -206,7 +121,6 @@ void orCombinesSpecificationsInOrder() { Predicate secondPredicate = mock(Predicate.class); Specification first = ((root1, query1, criteriaBuilder) -> firstPredicate); - Specification second = ((root1, query1, criteriaBuilder) -> secondPredicate); first.or(second).toPredicate(root, query, builder); @@ -214,6 +128,42 @@ void orCombinesSpecificationsInOrder() { verify(builder).or(firstPredicate, secondPredicate); } + @Test // GH-3849, GH-4023 + void notWithNullPredicate() { + + Specification notSpec = Specification.not(Specification.unrestricted()); + + assertThat(notSpec.toPredicate(root, query, builder)).isNull(); + verifyNoInteractions(builder); + } + + @Test // GH-3992 + void whereWithSpecificationReturnsSameSpecification() { + + Specification originalSpec = (r, q, cb) -> predicate; + Specification wrappedSpec = Specification.where(originalSpec); + + assertThat(wrappedSpec).isSameAs(originalSpec); + } + + @Test // GH-3992 + void whereWithSpecificationSupportsFluentComposition() { + + Specification firstSpec = (r, q, cb) -> predicate; + Specification secondSpec = (r, q, cb) -> predicate; + + Specification composedSpec = Specification.where(firstSpec).and(secondSpec); + + assertThat(composedSpec).isNotNull(); + composedSpec.toPredicate(root, query, builder); + verify(builder).and(predicate, predicate); + } + + @Test // GH-3992 + void whereWithNullSpecificationThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> Specification.where((Specification) null)); + } + static class SerializableSpecification implements Serializable, Specification { @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..a5415a3bd1 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/UpdateSpecificationUnitTests.java @@ -0,0 +1,178 @@ +/* + * 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.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 + * @author Peter Aisher + */ +@SuppressWarnings({ "unchecked", "deprecation" }) +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class UpdateSpecificationUnitTests implements Serializable { + + private UpdateSpecification spec; + @Mock(serializable = true) Root root; + @Mock(serializable = true) CriteriaUpdate 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 specification = UpdateSpecification.unrestricted(); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, update, builder)).isNull(); + } + + @Test // GH-3521 + void allOfCombinesPredicatesInOrder() { + + UpdateSpecification specification = UpdateSpecification.allOf(spec); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, update, builder)).isSameAs(predicate); + } + + @Test // GH-3521 + void anyOfCombinesPredicatesInOrder() { + + UpdateSpecification specification = UpdateSpecification.allOf(spec); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, update, builder)).isSameAs(predicate); + } + + @Test // GH-3521 + void emptyAllOfReturnsEmptySpecification() { + + UpdateSpecification specification = UpdateSpecification.allOf(); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, update, builder)).isNull(); + } + + @Test // GH-3521 + void emptyAnyOfReturnsEmptySpecification() { + + UpdateSpecification specification = UpdateSpecification.anyOf(); + + assertThat(specification).isNotNull(); + assertThat(specification.toPredicate(root, update, builder)).isNull(); + } + + @Test // GH-3521 + void specificationsShouldBeSerializable() { + + UpdateSpecification serializableSpec = new SerializableSpecification(); + UpdateSpecification specification = serializableSpec.and(serializableSpec); + + assertThat(specification).isNotNull(); + + UpdateSpecification transferredSpecification = (UpdateSpecification) deserialize( + serialize(specification)); + + assertThat(transferredSpecification).isNotNull(); + } + + @Test // GH-3521 + void complexSpecificationsShouldBeSerializable() { + + SerializableSpecification serializableSpec = new SerializableSpecification(); + UpdateSpecification specification = UpdateSpecification + .not(serializableSpec.and(serializableSpec).or(serializableSpec)); + + assertThat(specification).isNotNull(); + + UpdateSpecification transferredSpecification = (UpdateSpecification) deserialize( + serialize(specification)); + + assertThat(transferredSpecification).isNotNull(); + } + + @Test // GH-3521 + void andCombinesSpecificationsInOrder() { + + Predicate firstPredicate = mock(Predicate.class); + Predicate secondPredicate = mock(Predicate.class); + + UpdateSpecification first = ((root1, update, criteriaBuilder) -> firstPredicate); + UpdateSpecification 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 first = ((root1, update, criteriaBuilder) -> firstPredicate); + UpdateSpecification second = ((root1, update, criteriaBuilder) -> secondPredicate); + + first.or(second).toPredicate(root, update, builder); + + verify(builder).or(firstPredicate, secondPredicate); + } + + @Test // GH-3849, GH-4023 + void notWithNullPredicate() { + + UpdateSpecification notSpec = UpdateSpecification.not((r, q, cb) -> null); + + assertThat(notSpec.toPredicate(root, update, builder)).isNull(); + verifyNoInteractions(builder); + } + + static class SerializableSpecification implements Serializable, UpdateSpecification { + + @Override + public Predicate toPredicate(Root root, CriteriaUpdate update, CriteriaBuilder cb) { + return null; + } + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AbstractAnnotatedAuditable.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AbstractAnnotatedAuditable.java index 82adedca45..e3e2ee3472 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AbstractAnnotatedAuditable.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AbstractAnnotatedAuditable.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AbstractMappedType.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AbstractMappedType.java index 60a37337b0..801b3fd9c9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AbstractMappedType.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AbstractMappedType.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Account.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Account.java index 9b0fcbb036..787c4bd64f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Account.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Account.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -25,5 +25,4 @@ @Entity public class Account extends AbstractPersistable { - private static final long serialVersionUID = -5719129808165758887L; } 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 468a3ba193..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -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/AnnotatedAuditableUser.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AnnotatedAuditableUser.java index cbb435fbc2..44bcc91e6f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AnnotatedAuditableUser.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AnnotatedAuditableUser.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableEmbeddable.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableEmbeddable.java index 2cb8553073..40563a3f52 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableEmbeddable.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableEmbeddable.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableEntity.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableEntity.java index 8cb2b861c8..730fe36d20 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableEntity.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableRole.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableRole.java index 855b0ded60..bfc17a68d2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableRole.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableRole.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -27,8 +27,6 @@ @Entity public class AuditableRole extends AbstractAuditable { - private static final long serialVersionUID = 5997359055260303863L; - private String name; public void setName(String name) { 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 794b2cd899..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -15,16 +15,17 @@ */ package org.springframework.data.jpa.domain.sample; -import java.util.HashSet; -import java.util.Set; - import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.ManyToMany; import jakarta.persistence.NamedQuery; +import java.util.HashSet; +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 @@ -37,8 +38,6 @@ @NamedQuery(name = "AuditableUser.findByFirstname", query = "SELECT u FROM AuditableUser u WHERE u.firstname = ?1") public class AuditableUser extends AbstractAuditable { - private static final long serialVersionUID = 7409344446795693011L; - private String firstname; @ManyToMany( diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditorAwareStub.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditorAwareStub.java index 2ef54a1ff6..00124d98db 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditorAwareStub.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditorAwareStub.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Book.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Book.java index e66c26ffab..e30fd93869 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Book.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Book.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Child.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Child.java index 05673e35b0..f3df1a4fe2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Child.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Child.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ConcreteType1.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ConcreteType1.java index 90be74aeb7..28fb4e588f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ConcreteType1.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ConcreteType1.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ConcreteType2.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ConcreteType2.java index 356cc35d52..a30d7b4140 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ConcreteType2.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ConcreteType2.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Country.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Country.java new file mode 100644 index 0000000000..e02b800bd3 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Country.java @@ -0,0 +1,41 @@ +/* + * 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.domain.sample; + +/** + * @author Mark Paluch + */ +public class Country { + + private final String code; + + // workaround to avoid DTO projections as needsCustomConstruction is false. + private Country(Country other) { + this.code = other.code; + } + + private Country(String code) { + this.code = code; + } + + public static Country of(String code) { + return new Country(code); + } + + public String getCode() { + return code; + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaJpaRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/CountryConverter.java similarity index 54% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaJpaRepositoryTests.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/CountryConverter.java index 10440974b2..5b9b55a7a9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaJpaRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/CountryConverter.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 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,21 @@ * 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.domain.sample; -import org.junit.jupiter.api.Disabled; -import org.springframework.test.context.ContextConfiguration; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; -/** - * Integration tests to execute {@link JpaRepositoryTests} against OpenJpa. - * - * @author Oliver Gierke - */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaJpaRepositoryTests extends JpaRepositoryTests { +@Converter(autoApply = true) +public class CountryConverter implements AttributeConverter { + + @Override + public String convertToDatabaseColumn(Country attribute) { + return attribute.getCode(); + } @Override - @Disabled - void testCrudOperationsForCompoundKeyEntity() { + public Country convertToEntityAttribute(String dbData) { + return Country.of(dbData); } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/CustomAbstractPersistable.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/CustomAbstractPersistable.java index 7209fa41fc..dd3fd70449 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/CustomAbstractPersistable.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/CustomAbstractPersistable.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -27,5 +27,4 @@ @Table(name = "customAbstractPersistable") public class CustomAbstractPersistable extends AbstractPersistable { - private static final long serialVersionUID = 1L; } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Customer.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Customer.java index 72d01a6267..875d724c13 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Customer.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Customer.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -15,17 +15,45 @@ */ package org.springframework.data.jpa.domain.sample; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.Id; /** * @author Oliver Gierke * @author Patrice Blanchardie + * @author Mark Paluch */ @Entity public class Customer { - @Id Long id; + @Id Long id; - String name; + String name; + + @Convert(converter = CountryConverter.class) Country country; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Country getCountry() { + return country; + } + + public void setCountry(Country country) { + this.country = country; + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Dummy.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Dummy.java index e30afcd084..bb8bfe638a 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Dummy.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Dummy.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleDepartment.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleDepartment.java index 043457222a..a7aa1b9dfc 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleDepartment.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleDepartment.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleEmployee.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleEmployee.java index 0368411310..218c1f6e01 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleEmployee.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleEmployee.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleEmployeePK.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleEmployeePK.java index f492e758ec..393bb6b668 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleEmployeePK.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmbeddedIdExampleEmployeePK.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -15,6 +15,7 @@ */ package org.springframework.data.jpa.domain.sample; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.Column; @@ -25,7 +26,7 @@ */ @Embeddable public class EmbeddedIdExampleEmployeePK implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Column(nullable = false) private Long employeeId; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java index ee4f273f10..4668ed04de 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EmployeeWithName.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EntityWithAssignedId.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EntityWithAssignedId.java index 3204f31995..4226b8bb67 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EntityWithAssignedId.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/EntityWithAssignedId.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2024 the original author or authors. + * Copyright 2019-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleDepartment.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleDepartment.java index 2539184afb..7db4bf3486 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleDepartment.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleDepartment.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleEmployee.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleEmployee.java index 18df94dcec..6ad0aeb024 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleEmployee.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleEmployee.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleEmployeePK.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleEmployeePK.java index 094f965554..fd730da59d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleEmployeePK.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/IdClassExampleEmployeePK.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -15,13 +15,14 @@ */ package org.springframework.data.jpa.domain.sample; +import java.io.Serial; import java.io.Serializable; /** * @author Thomas Darimont */ public class IdClassExampleEmployeePK implements Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; private long empId; private long department; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Invoice.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Invoice.java index 2f5381f494..a0458759f6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Invoice.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Invoice.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/InvoiceItem.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/InvoiceItem.java index 1ff39954df..c791359af3 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/InvoiceItem.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/InvoiceItem.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Item.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Item.java index 1625c2a794..7a01bdf620 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Item.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Item.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemId.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemId.java index 3d65578cce..b58f92d049 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemId.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemId.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. @@ -15,6 +15,7 @@ */ package org.springframework.data.jpa.domain.sample; +import java.io.Serial; import java.io.Serializable; /** @@ -25,7 +26,7 @@ */ public class ItemId implements Serializable { - private static final long serialVersionUID = -2986871112875450036L; + @Serial private static final long serialVersionUID = -2986871112875450036L; private Integer id; private Integer manufacturerId; @@ -57,11 +58,9 @@ public void setManufacturerId(Integer manufacturerId) { public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof ItemId)) + if (!(o instanceof ItemId itemId)) return false; - ItemId itemId = (ItemId) o; - if (id != null ? !id.equals(itemId.id) : itemId.id != null) return false; return manufacturerId != null ? manufacturerId.equals(itemId.manufacturerId) : itemId.manufacturerId == null; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemSite.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemSite.java index b5da989a14..f5ad83e344 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemSite.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemSite.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemSiteId.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemSiteId.java index 4b9f2bc159..4d838de5e6 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemSiteId.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ItemSiteId.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. @@ -15,6 +15,7 @@ */ package org.springframework.data.jpa.domain.sample; +import java.io.Serial; import java.io.Serializable; /** @@ -25,7 +26,7 @@ */ public class ItemSiteId implements Serializable { - private static final long serialVersionUID = 1822540289216799357L; + @Serial private static final long serialVersionUID = 1822540289216799357L; private ItemId item; private Integer site; @@ -41,11 +42,9 @@ public ItemSiteId(ItemId item, Integer site) { public boolean equals(Object o) { if (this == o) return true; - if (!(o instanceof ItemSiteId)) + if (!(o instanceof ItemSiteId that)) return false; - ItemSiteId that = (ItemSiteId) o; - if (item != null ? !item.equals(that.item) : that.item != null) return false; return site != null ? site.equals(that.site) : that.site == null; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailMessage.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailMessage.java index 08d5975559..065e9ec59b 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailMessage.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailMessage.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailSender.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailSender.java index a15de9c1f1..00fffaa244 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailSender.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailSender.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailUser.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailUser.java index 49911d316a..16d5b2dbcd 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailUser.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/MailUser.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Order.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Order.java index c9146c1bf6..2c544945b7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Order.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Order.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/OrmXmlEntity.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/OrmXmlEntity.java index b18eb9f18b..48ba2cdeb2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/OrmXmlEntity.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/OrmXmlEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Owner.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Owner.java index 7661f038a1..820fc189fd 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Owner.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Owner.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/OwnerContainer.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/OwnerContainer.java index 177b8b48ba..f726ca86c1 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/OwnerContainer.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/OwnerContainer.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Parent.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Parent.java index ed392f102e..4b9e57c4fe 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Parent.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Parent.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -15,15 +15,15 @@ */ package org.springframework.data.jpa.domain.sample; -import java.util.HashSet; -import java.util.Set; - import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.ManyToMany; +import java.util.HashSet; +import java.util.Set; + @Entity public class Parent { @@ -31,8 +31,6 @@ public class Parent { @GeneratedValue Long id; - static final long serialVersionUID = -89717120680485957L; - @ManyToMany(cascade = CascadeType.ALL) Set children = new HashSet<>(); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithIdClass.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithIdClass.java index 6ccab3d688..08a31a09bf 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithIdClass.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithIdClass.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -30,8 +30,6 @@ @IdClass(PersistableWithIdClassPK.class) public class PersistableWithIdClass implements Persistable { - private static final long serialVersionUID = 1L; - @Id private Long first; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithIdClassPK.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithIdClassPK.java index 4b830e5ec4..0dcef9e938 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithIdClassPK.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithIdClassPK.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -17,6 +17,7 @@ import static org.springframework.util.ObjectUtils.*; +import java.io.Serial; import java.io.Serializable; /** @@ -25,7 +26,7 @@ */ public class PersistableWithIdClassPK implements Serializable { - private static final long serialVersionUID = 23126782341L; + @Serial private static final long serialVersionUID = 23126782341L; private Long first; private Long second; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithSingleIdClass.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithSingleIdClass.java index 9abf2d8a68..cefd449fc9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithSingleIdClass.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithSingleIdClass.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. @@ -28,8 +28,6 @@ @IdClass(PersistableWithSingleIdClassPK.class) public class PersistableWithSingleIdClass { - private static final long serialVersionUID = 1L; - @Id private Long first; protected PersistableWithSingleIdClass() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithSingleIdClassPK.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithSingleIdClassPK.java index 4c90313fe8..6466fd1dd8 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithSingleIdClassPK.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PersistableWithSingleIdClassPK.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. @@ -17,6 +17,7 @@ import static org.springframework.util.ObjectUtils.*; +import java.io.Serial; import java.io.Serializable; /** @@ -24,7 +25,7 @@ */ public class PersistableWithSingleIdClassPK implements Serializable { - private static final long serialVersionUID = 23126782341L; + @Serial private static final long serialVersionUID = 23126782341L; private Long first; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PrimitiveVersionProperty.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PrimitiveVersionProperty.java index c3531adacc..694f896df8 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PrimitiveVersionProperty.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/PrimitiveVersionProperty.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ReferencingEmbeddedIdExampleEmployee.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ReferencingEmbeddedIdExampleEmployee.java new file mode 100644 index 0000000000..7675699c9d --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ReferencingEmbeddedIdExampleEmployee.java @@ -0,0 +1,44 @@ +/* + * 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.domain.sample; + + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; + +@Entity +public class ReferencingEmbeddedIdExampleEmployee { + + @Id private Long id; + @ManyToOne private EmbeddedIdExampleEmployee employee; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public EmbeddedIdExampleEmployee getEmployee() { + return employee; + } + + public void setEmployee(EmbeddedIdExampleEmployee employee) { + this.employee = employee; + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ReferencingIdClassExampleEmployee.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ReferencingIdClassExampleEmployee.java new file mode 100644 index 0000000000..301f42b36e --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/ReferencingIdClassExampleEmployee.java @@ -0,0 +1,44 @@ +/* + * 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.domain.sample; + + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.ManyToOne; + +@Entity +public class ReferencingIdClassExampleEmployee { + + @Id private Long id; + @ManyToOne private IdClassExampleEmployee employee; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public IdClassExampleEmployee getEmployee() { + return employee; + } + + public void setEmployee(IdClassExampleEmployee employee) { + this.employee = employee; + } +} 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 f9518a8fbc..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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/SampleEntity.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleEntity.java index 8fe586d843..3fafe176f1 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleEntity.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleEntity.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleEntityPK.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleEntityPK.java index eec8849df3..2ad2f36f3f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleEntityPK.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleEntityPK.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -15,6 +15,7 @@ */ package org.springframework.data.jpa.domain.sample; +import java.io.Serial; import java.io.Serializable; import jakarta.persistence.Column; @@ -25,7 +26,7 @@ @Embeddable public class SampleEntityPK implements Serializable { - private static final long serialVersionUID = 231060947L; + @Serial private static final long serialVersionUID = 231060947L; @Column(nullable = false) private String first; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithIdClass.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithIdClass.java index ba1cef184a..d029ba5c96 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithIdClass.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithIdClass.java @@ -1,13 +1,13 @@ package org.springframework.data.jpa.domain.sample; -import java.io.Serializable; - import jakarta.persistence.Access; import jakarta.persistence.AccessType; import jakarta.persistence.Entity; import jakarta.persistence.Id; import jakarta.persistence.IdClass; +import java.io.Serializable; + @Entity @IdClass(SampleWithIdClass.SampleWithIdClassPK.class) @Access(AccessType.FIELD) @@ -29,12 +29,10 @@ public boolean equals(Object obj) { return true; } - if (!(obj instanceof SampleWithIdClassPK)) { + if (!(obj instanceof SampleWithIdClassPK that)) { return false; } - SampleWithIdClassPK that = (SampleWithIdClassPK) obj; - return this.first.equals(that.first) && this.second.equals(that.second); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithPrimitiveId.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithPrimitiveId.java index a84c1409f5..f1047f1d4f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithPrimitiveId.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithPrimitiveId.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithTimestampVersion.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithTimestampVersion.java index 7dc7d4ab59..3b17fb3a59 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithTimestampVersion.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/SampleWithTimestampVersion.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Site.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Site.java index 4bc3683b95..591f4da96e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Site.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Site.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. @@ -15,6 +15,8 @@ */ package org.springframework.data.jpa.domain.sample; +import java.io.Serial; + import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; @@ -31,7 +33,7 @@ @Table public class Site implements java.io.Serializable { - private static final long serialVersionUID = 1L; + @Serial private static final long serialVersionUID = 1L; @Id @GeneratedValue Integer 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 d98ed10d6a..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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", @@ -83,8 +85,11 @@ @StoredProcedureParameter(mode = ParameterMode.OUT, name = "res", type = Integer.class) }) // Annotations for native Query with pageable -@SqlResultSetMappings({ - @SqlResultSetMapping(name = "SqlResultSetMapping.count", columns = @ColumnResult(name = "cnt")) }) +@SqlResultSetMappings({ @SqlResultSetMapping(name = "SqlResultSetMapping.count", columns = @ColumnResult(name = "cnt")), + @SqlResultSetMapping(name = "emailDto", + classes = { @ConstructorResult(targetClass = User.EmailDto.class, + columns = { @ColumnResult(name = "emailaddress", type = String.class), + @ColumnResult(name = "secondary_email_address", type = String.class) }) }) }) @NamedNativeQueries({ @NamedNativeQuery(name = "User.findByNativeNamedQueryWithPageable", resultClass = User.class, query = "SELECT * FROM SD_USER ORDER BY UCASE(firstname)"), @@ -93,7 +98,26 @@ @Table(name = "SD_User") public class User { - @Id @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; + public static class EmailDto { + private final String one; + private final String two; + + public EmailDto(String one, String two) { + this.one = one; + this.two = two; + } + + public String getOne() { + return one; + } + + public String getTwo() { + return two; + } + } + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) private Integer id; private String firstname; private String lastname; private int age; @@ -262,12 +286,10 @@ public byte[] getBinaryData() { @Override public boolean equals(Object obj) { - if (!(obj instanceof User)) { + if (!(obj instanceof User that)) { return false; } - User that = (User) obj; - if ((null == this.getId()) || (null == that.getId())) { return false; } 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 3b4d0cbbcd..bd65c8403c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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,27 +26,27 @@ */ public class UserSpecifications { - public static Specification userHasFirstname(final String firstname) { + public static PredicateSpecification userHasFirstname(String firstname) { return simplePropertySpec("firstname", firstname); } - public static Specification userHasLastname(final String lastname) { + public static PredicateSpecification userHasLastname(String lastname) { return simplePropertySpec("lastname", lastname); } - public static Specification userHasFirstnameLike(final String expression) { + public static PredicateSpecification userHasFirstnameLike(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 userHasAgeLess(final Integer age) { + public static PredicateSpecification userHasAgeLess(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 userHasLastnameLikeWithSort(final String expression) { + public static Specification userHasLastnameLikeWithSort(String expression) { return (root, query, cb) -> { @@ -55,8 +56,8 @@ public static Specification userHasLastnameLikeWithSort(final String expre }; } - private static Specification simplePropertySpec(final String property, final Object value) { + private static PredicateSpecification simplePropertySpec(String property, Object value) { - return (root, query, builder) -> builder.equal(root.get(property), value); + return (from, builder) -> builder.equal(from.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 b4029f4717..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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/domain/sample/UserWithOptionalFieldRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserWithOptionalFieldRepository.java index 348b4433c6..c2ead4cdf7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserWithOptionalFieldRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserWithOptionalFieldRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/VersionedUser.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/VersionedUser.java index 5e5927e1e4..58ff336205 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/VersionedUser.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/VersionedUser.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AbstractAttributeConverterIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AbstractAttributeConverterIntegrationTests.java index c5a088ddec..34fb8b1bf4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AbstractAttributeConverterIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AbstractAttributeConverterIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AnnotationAuditingBeanFactoryPostProcessorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AnnotationAuditingBeanFactoryPostProcessorUnitTests.java index 39160aee50..94702362a0 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AnnotationAuditingBeanFactoryPostProcessorUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AnnotationAuditingBeanFactoryPostProcessorUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingBeanFactoryPostProcessorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingBeanFactoryPostProcessorUnitTests.java index e34cec061d..c5ed0d79df 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingBeanFactoryPostProcessorUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingBeanFactoryPostProcessorUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingEntityListenerTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingEntityListenerTests.java index e95c8b0542..674fef7bf9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingEntityListenerTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingEntityListenerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingEntityWithEmbeddableListenerTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingEntityWithEmbeddableListenerTests.java index c728da9176..6701ddc4b9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingEntityWithEmbeddableListenerTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingEntityWithEmbeddableListenerTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -17,9 +17,6 @@ import static org.assertj.core.api.Assertions.*; -import java.time.Instant; -import java.util.Optional; - import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -34,6 +31,7 @@ * Integration test for {@link AuditingEntityListener}. * * @author Greg Turnquist + * @author Oliver Drotbohm */ @ExtendWith(SpringExtension.class) @ContextConfiguration("classpath:auditing/auditing-entity-with-embeddable-listener.xml") @@ -48,7 +46,6 @@ class AuditingEntityWithEmbeddableListenerTests { void setUp() { entity = new AuditableEntity(); - entity.setId(1L); entity.setData("original value"); auditDetails = new AuditableEmbeddable(); @@ -60,32 +57,30 @@ void auditsEmbeddedCorrectly() { // when repository.saveAndFlush(entity); - Optional optionalEntity = repository.findById(1L); // then - assertThat(optionalEntity).isNotEmpty(); - AuditableEntity auditableEntity = optionalEntity.get(); - assertThat(auditableEntity.getData()).isEqualTo("original value"); + assertThat(repository.findById(1L)).hasValueSatisfying(it -> { - assertThat(auditableEntity.getAuditDetails().getDateCreated()).isNotNull(); - assertThat(auditableEntity.getAuditDetails().getDateUpdated()).isNotNull(); + assertThat(it.getData()).isEqualTo("original value"); - Instant originalCreationDate = auditableEntity.getAuditDetails().getDateCreated(); - Instant originalDateUpdated = auditableEntity.getAuditDetails().getDateUpdated(); + AuditableEmbeddable details = it.getAuditDetails(); - auditableEntity.setData("updated value"); + assertThat(details.getDateCreated()).isNotNull(); + assertThat(details.getDateUpdated()).isNotNull(); - repository.saveAndFlush(auditableEntity); + it.setData("updated value"); + repository.saveAndFlush(it); - Optional optionalRevisedEntity = repository.findById(1L); + assertThat(repository.findById(1L)).hasValueSatisfying(revised -> { - assertThat(optionalRevisedEntity).isNotEmpty(); + assertThat(revised.getData()).isEqualTo("updated value"); - AuditableEntity revisedEntity = optionalRevisedEntity.get(); - assertThat(revisedEntity.getData()).isEqualTo("updated value"); + AuditableEmbeddable revisedDetails = revised.getAuditDetails(); - assertThat(revisedEntity.getAuditDetails().getDateCreated()).isEqualTo(originalCreationDate); - assertThat(revisedEntity.getAuditDetails().getDateUpdated()).isAfter(originalDateUpdated); + assertThat(revisedDetails.getDateCreated()).isEqualTo(details.getDateCreated()); + assertThat(revisedDetails.getDateUpdated()).isAfter(details.getDateUpdated()); + }); + }); } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingNamespaceUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingNamespaceUnitTests.java index 2835201104..510793ad72 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingNamespaceUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/AuditingNamespaceUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/QueryByExampleWithOptionalEmptyTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/QueryByExampleWithOptionalEmptyTests.java index d5711367ef..21c04b3145 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/QueryByExampleWithOptionalEmptyTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/support/QueryByExampleWithOptionalEmptyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. 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 93ff873092..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -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/HibernateMetamodelIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/HibernateMetamodelIntegrationTests.java index 284287c990..6ecaceccbc 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/HibernateMetamodelIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/HibernateMetamodelIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/HibernateTestUtils.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/HibernateTestUtils.java index 82efcfadd3..02d29a7673 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/HibernateTestUtils.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/HibernateTestUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/MetamodelIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/MetamodelIntegrationTests.java index 2f48b244b4..c821dd76a5 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/MetamodelIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/MetamodelIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. 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 8e437f6473..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/OpenJpaMetamodelIntegrationTests.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2013-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.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/mapping/JpaMetamodelMappingContextIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContextIntegrationTests.java index 3ed9eaf357..4e2d3c32ea 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContextIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContextIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContextUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContextUnitTests.java index ff83c9672e..03962c2aa6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContextUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContextUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImplUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImplUnitTests.java index e7d42e9e76..ef3af850a1 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImplUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImplUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderIntegrationTests.java index b28943e2d5..0f27bd1422 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderUnitTests.java index aa063927cf..bb3c0aa161 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -16,22 +16,29 @@ package org.springframework.data.jpa.provider; import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; import static org.springframework.data.jpa.provider.PersistenceProvider.*; import static org.springframework.data.jpa.provider.PersistenceProvider.Constants.*; -import java.util.ArrayList; -import java.util.List; - import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceException; + +import java.lang.reflect.Proxy; +import java.util.Arrays; +import java.util.Map; -import org.assertj.core.api.Assumptions; -import org.hibernate.Version; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.Mockito; + import org.springframework.asm.ClassWriter; import org.springframework.asm.Opcodes; import org.springframework.instrument.classloading.ShadowingClassLoader; +import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.ClassUtils; /** @@ -40,6 +47,7 @@ * @author Thomas Darimont * @author Oliver Gierke * @author Jens Schauder + * @author Mark Paluch */ class PersistenceProviderUnitTests { @@ -48,17 +56,38 @@ class PersistenceProviderUnitTests { @BeforeEach void setup() { - PersistenceProvider.CACHE.clear(); + Map cache = (Map) ReflectionTestUtils.getField(PersistenceProvider.class, "CACHE"); + cache.clear(); this.shadowingClassLoader = new ShadowingClassLoader(getClass().getClassLoader()); } + @ParameterizedTest // GH-3425 + @EnumSource(PersistenceProvider.class) + void entityManagerFactoryClassNamesAreInterfaces(PersistenceProvider provider) throws ClassNotFoundException { + + for (String className : provider.entityManagerFactoryClassNames) { + assertThat(ClassUtils.forName(className, PersistenceProvider.class.getClassLoader()).isInterface()).isTrue(); + } + } + + @ParameterizedTest // GH-3425 + @EnumSource(PersistenceProvider.class) + void metaModelNamesExist(PersistenceProvider provider) throws ClassNotFoundException { + + for (String className : provider.entityManagerFactoryClassNames) { + assertThat(ClassUtils.forName(className, PersistenceProvider.class.getClassLoader()).isInterface()).isNotNull(); + } + } + @Test void detectsEclipseLinkPersistenceProvider() throws Exception { shadowingClassLoader.excludePackage("org.eclipse.persistence.jpa"); EntityManager em = mockProviderSpecificEntityManagerInterface(ECLIPSELINK_ENTITY_MANAGER_INTERFACE); + when(em.getEntityManagerFactory()) + .thenReturn(mockProviderSpecificEntityManagerFactoryInterface(ECLIPSELINK_ENTITY_MANAGER_FACTORY_INTERFACE)); assertThat(fromEntityManager(em)).isEqualTo(ECLIPSELINK); } @@ -67,33 +96,40 @@ void detectsEclipseLinkPersistenceProvider() throws Exception { void fallbackToGenericJpaForUnknownPersistenceProvider() throws Exception { EntityManager em = mockProviderSpecificEntityManagerInterface("foo.bar.unknown.jpa.JpaEntityManager"); + when(em.getEntityManagerFactory()).thenReturn(mock(EntityManagerFactory.class)); assertThat(fromEntityManager(em)).isEqualTo(GENERIC_JPA); } - @Test // DATAJPA-1019 - void detectsHibernatePersistenceProviderForHibernateVersion52() throws Exception { - - Assumptions.assumeThat(Version.getVersionString()).startsWith("5.2"); + @Test // DATAJPA-1379 + void detectsProviderFromProxiedEntityManager() throws Exception { - shadowingClassLoader.excludePackage("org.hibernate"); + shadowingClassLoader.excludePackage("org.eclipse.persistence.jpa"); - EntityManager em = mockProviderSpecificEntityManagerInterface(HIBERNATE_ENTITY_MANAGER_INTERFACE); + EntityManager emProxy = Mockito.mock(EntityManager.class); + when(emProxy.getEntityManagerFactory()) + .thenReturn(mockProviderSpecificEntityManagerFactoryInterface(ECLIPSELINK_ENTITY_MANAGER_FACTORY_INTERFACE)); - assertThat(fromEntityManager(em)).isEqualTo(HIBERNATE); + assertThat(fromEntityManager(emProxy)).isEqualTo(ECLIPSELINK); } - @Test // DATAJPA-1379 - void detectsProviderFromProxiedEntityManager() throws Exception { + @Test // GH-3923 + void detectsEntityManagerFromProxiedEntityManagerFactory() throws Exception { - shadowingClassLoader.excludePackage("org.eclipse.persistence.jpa"); + EntityManagerFactory emf = mockProviderSpecificEntityManagerFactoryInterface( + "foo.bar.unknown.jpa.JpaEntityManager"); + when(emf.unwrap(null)).thenThrow(new NullPointerException()); + when(emf.unwrap(EntityManagerFactory.class)).thenReturn(emf); - EntityManager em = mockProviderSpecificEntityManagerInterface(ECLIPSELINK_ENTITY_MANAGER_INTERFACE); + MyEntityManagerFactoryBean factoryBean = new MyEntityManagerFactoryBean(EntityManagerFactory.class, emf); + EntityManagerFactory springProxy = factoryBean.createEntityManagerFactoryProxy(emf); - EntityManager emProxy = Mockito.mock(EntityManager.class); - Mockito.when(emProxy.getDelegate()).thenReturn(em); + Object externalProxy = Proxy.newProxyInstance(getClass().getClassLoader(), + new Class[] { EntityManagerFactory.class }, (proxy, method, args) -> method.invoke(emf, args)); - assertThat(fromEntityManager(emProxy)).isEqualTo(ECLIPSELINK); + assertThat(PersistenceProvider.fromEntityManagerFactory(springProxy)).isEqualTo(GENERIC_JPA); + assertThat(PersistenceProvider.fromEntityManagerFactory((EntityManagerFactory) externalProxy)) + .isEqualTo(GENERIC_JPA); } private EntityManager mockProviderSpecificEntityManagerInterface(String interfaceName) throws ClassNotFoundException { @@ -102,21 +138,31 @@ private EntityManager mockProviderSpecificEntityManagerInterface(String interfac EntityManager.class); EntityManager em = (EntityManager) Mockito.mock(providerSpecificEntityManagerInterface); - Mockito.when(em.getDelegate()).thenReturn(em); // delegate is used to determine the classloader of the provider - // specific interface, therefore we return the proxied - // EntityManager. + + // delegate is used to determine the classloader of the provider + // specific interface, therefore we return the proxied EntityManager + when(em.getDelegate()).thenReturn(em); return em; } + private EntityManagerFactory mockProviderSpecificEntityManagerFactoryInterface(String interfaceName) + throws ClassNotFoundException { + + Class providerSpecificEntityManagerInterface = InterfaceGenerator.generate(interfaceName, shadowingClassLoader, + EntityManagerFactory.class); + + return (EntityManagerFactory) Mockito.mock(providerSpecificEntityManagerInterface); + } + static class InterfaceGenerator implements Opcodes { - static Class generate(final String interfaceName, ClassLoader parentClassLoader, - final Class... interfaces) throws ClassNotFoundException { + static Class generate(final String interfaceName, ClassLoader parentClassLoader, final Class... interfaces) + throws ClassNotFoundException { class CustomClassLoader extends ClassLoader { - CustomClassLoader(ClassLoader parent) { + private CustomClassLoader(ClassLoader parent) { super(parent); } @@ -151,12 +197,30 @@ private static byte[] generateByteCodeForInterface(final String interfaceName, C private static String[] toResourcePaths(Class... interfacesToImplement) { - List interfaceResourcePaths = new ArrayList<>(interfacesToImplement.length); - for (Class iface : interfacesToImplement) { - interfaceResourcePaths.add(ClassUtils.convertClassNameToResourcePath(iface.getName())); - } + return Arrays.stream(interfacesToImplement) // + .map(Class::getName) // + .map(ClassUtils::convertClassNameToResourcePath) // + .toArray(String[]::new); + } + } + + static class MyEntityManagerFactoryBean extends AbstractEntityManagerFactoryBean { + + public MyEntityManagerFactoryBean(Class entityManagerFactoryInterface, + EntityManagerFactory entityManagerFactory) { + setEntityManagerFactoryInterface(entityManagerFactoryInterface); + ReflectionTestUtils.setField(this, "nativeEntityManagerFactory", entityManagerFactory); + + } + + @Override + protected EntityManagerFactory createNativeEntityManagerFactory() throws PersistenceException { + return null; + } - return interfaceResourcePaths.toArray(new String[0]); + @Override + protected EntityManagerFactory createEntityManagerFactoryProxy(EntityManagerFactory emf) { + return super.createEntityManagerFactoryProxy(emf); } } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AbstractPersistableIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AbstractPersistableIntegrationTests.java index 67ce3cfa50..bad12168d4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AbstractPersistableIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AbstractPersistableIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AbstractVectorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AbstractVectorIntegrationTests.java new file mode 100644 index 0000000000..71538f9dff --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AbstractVectorIntegrationTests.java @@ -0,0 +1,371 @@ +/* + * 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 static org.assertj.core.api.Assertions.assertThat; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import org.hibernate.annotations.Array; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Score; +import org.springframework.data.domain.ScoringFunction; +import org.springframework.data.domain.SearchResult; +import org.springframework.data.domain.SearchResults; +import org.springframework.data.domain.Similarity; +import org.springframework.data.domain.Vector; +import org.springframework.data.domain.VectorScoringFunctions; +import org.springframework.test.annotation.Rollback; +import org.springframework.transaction.annotation.Transactional; + +/** + * Testcase to verify Vector Search work with Hibernate. + * + * @author Mark Paluch + * @author Christoph Strobl + */ +@Transactional +@Rollback(value = false) +abstract class AbstractVectorIntegrationTests { + + Vector VECTOR = Vector.of(0.2001f, 0.32345f, 0.43456f, 0.54567f, 0.65678f); + + @Autowired VectorSearchRepository repository; + + @BeforeEach + void setUp() { + + WithVector w1 = new WithVector("de", "one", "d1", new float[] { 0.1001f, 0.22345f, 0.33456f, 0.44567f, 0.55678f }); + WithVector w2 = new WithVector("de", "two", "d2", new float[] { 0.2001f, 0.32345f, 0.43456f, 0.54567f, 0.65678f }); + WithVector w3 = new WithVector("en", "three", "d3", + new float[] { 0.9001f, 0.82345f, 0.73456f, 0.64567f, 0.55678f }); + WithVector w4 = new WithVector("de", "four", "d4", new float[] { 0.9001f, 0.92345f, 0.93456f, 0.94567f, 0.95678f }); + + repository.deleteAllInBatch(); + repository.saveAllAndFlush(Arrays.asList(w1, w2, w3, w4)); + } + + @ParameterizedTest + @MethodSource("scoringFunctions") + void shouldApplyVectorSearchWithDistance(VectorScoringFunctions functions) { + + SearchResults results = repository.searchTop5ByCountryAndEmbeddingWithin("de", VECTOR, + Similarity.of(0, functions)); + + assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithVector::getCountry) + .containsOnly("de", "de"); + + assertThat(results).extracting(SearchResult::getContent).extracting(WithVector::getDescription) + .containsExactlyInAnyOrder("two", "one", "four"); + } + + static Set scoringFunctions() { + return EnumSet.of(VectorScoringFunctions.COSINE, VectorScoringFunctions.DOT_PRODUCT, + VectorScoringFunctions.EUCLIDEAN); + } + + @Test // GH-3868 + void shouldNormalizeEuclideanSimilarity() { + + SearchResults results = repository.searchTop5ByCountryAndEmbeddingWithin("de", VECTOR, + Similarity.of(0.99, VectorScoringFunctions.EUCLIDEAN)); + + assertThat(results).hasSize(1); + + SearchResult two = results.getContent().get(0); + + assertThat(two.getContent().getDescription()).isEqualTo("two"); + assertThat(two.getScore()).isInstanceOf(Similarity.class); + assertThat(two.getScore().getValue()).isGreaterThan(0.99); + } + + @Test // GH-3868 + void orderTargetsProperty() { + + SearchResults results = repository.searchTop5ByCountryAndEmbeddingWithinOrderByDistance("de", VECTOR, + Similarity.of(0, VectorScoringFunctions.EUCLIDEAN)); + + assertThat(results.getContent()).extracting(it -> it.getContent().getDistance()).containsExactly("d1", "d2", "d4"); + } + + @Test// GH-3868 + void shouldNormalizeCosineSimilarity() { + + SearchResults results = repository.searchTop5ByCountryAndEmbeddingWithin("de", VECTOR, + Similarity.of(0.999, VectorScoringFunctions.COSINE)); + + assertThat(results).hasSize(1); + + SearchResult two = results.getContent().get(0); + + assertThat(two.getContent().getDescription()).isEqualTo("two"); + assertThat(two.getScore()).isInstanceOf(Similarity.class); + assertThat(two.getScore().getValue()).isGreaterThan(0.99); + } + + @Test // GH-3868 + void shouldRunStringQuery() { + + List results = repository.findAnnotatedByCountryAndEmbeddingWithin("de", VECTOR, + Score.of(2, VectorScoringFunctions.COSINE)); + + assertThat(results).hasSize(3).extracting(WithVector::getCountry).containsOnly("de", "de", "de"); + assertThat(results).extracting(WithVector::getDescription).containsSequence("two", "one", "four"); + } + + @Test // GH-3868 + void shouldRunStringQueryWithDistance() { + + SearchResults results = repository.searchAnnotatedByCountryAndEmbeddingWithin("de", VECTOR, + Score.of(2, VectorScoringFunctions.COSINE)); + + assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithVector::getCountry) + .containsOnly("de", "de", "de"); + assertThat(results).extracting(SearchResult::getContent).extracting(WithVector::getDescription) + .containsSequence("two", "one", "four"); + + SearchResult result = results.getContent().get(0); + assertThat(result.getScore().getValue()).isGreaterThanOrEqualTo(0); + assertThat(result.getScore().getFunction()).isEqualTo(VectorScoringFunctions.COSINE); + } + + @Test // GH-3868 + void shouldRunStringQueryWithFloatDistance() { + + SearchResults results = repository.searchAnnotatedByCountryAndEmbeddingWithin("de", VECTOR, 2); + + assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithVector::getCountry) + .containsOnly("de", "de", "de"); + assertThat(results).extracting(SearchResult::getContent).extracting(WithVector::getDescription) + .containsSequence("two", "one", "four"); + + SearchResult result = results.getContent().get(0); + assertThat(result.getScore().getValue()).isGreaterThanOrEqualTo(0); + assertThat(result.getScore().getFunction()).isEqualTo(ScoringFunction.unspecified()); + } + + @Test // GH-3868 + void shouldApplyVectorSearchWithRange() { + + SearchResults results = repository.searchAllByCountryAndEmbeddingWithin("de", VECTOR, + Similarity.between(0, 1, VectorScoringFunctions.COSINE)); + + assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithVector::getCountry) + .containsOnly("de", "de", "de"); + assertThat(results).extracting(SearchResult::getContent).extracting(WithVector::getDescription) + .containsSequence("two", "one", "four"); + } + + @Test // GH-3868 + void shouldApplyVectorSearchAndReturnList() { + + List results = repository.findAllByCountryAndEmbeddingWithin("de", VECTOR, + Score.of(10, VectorScoringFunctions.COSINE)); + + assertThat(results).hasSize(3).extracting(WithVector::getCountry).containsOnly("de", "de", "de"); + assertThat(results).extracting(WithVector::getDescription).containsSequence("one", "two", "four"); + } + + @Test // GH-3868 + void shouldProjectVectorSearchAsInterface() { + + SearchResults results = repository.searchInterfaceProjectionByCountryAndEmbeddingWithin("de", + VECTOR, Score.of(10, VectorScoringFunctions.COSINE)); + + assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(WithDescription::getDescription) + .containsSequence("two", "one", "four"); + } + + @Test // GH-3868 + void shouldProjectVectorSearchAsDto() { + + SearchResults results = repository.searchDtoByCountryAndEmbeddingWithin("de", VECTOR, + Score.of(10, VectorScoringFunctions.COSINE)); + + assertThat(results).hasSize(3).extracting(SearchResult::getContent).extracting(DescriptionDto::getDescription) + .containsSequence("two", "one", "four"); + } + + @Test // GH-3868 + void shouldProjectVectorSearchDynamically() { + + SearchResults dtos = repository.searchDynamicByCountryAndEmbeddingWithin("de", VECTOR, + Score.of(10, VectorScoringFunctions.COSINE), DescriptionDto.class); + + assertThat(dtos).hasSize(3).extracting(SearchResult::getContent).extracting(DescriptionDto::getDescription) + .containsSequence("two", "one", "four"); + + SearchResults proxies = repository.searchDynamicByCountryAndEmbeddingWithin("de", VECTOR, + Score.of(10, VectorScoringFunctions.COSINE), WithDescription.class); + + assertThat(proxies).hasSize(3).extracting(SearchResult::getContent).extracting(WithDescription::getDescription) + .containsSequence("two", "one", "four"); + } + + @Entity + @Table(name = "with_vector") + public static class WithVector { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) // + private Integer id; + + private String country; + private String description; + + private String distance; + + @Column(name = "the_embedding") + @JdbcTypeCode(SqlTypes.VECTOR) + @Array(length = 5) private float[] embedding; + + public WithVector() {} + + public WithVector(String country, String description, String distance, float[] embedding) { + this.country = country; + this.description = description; + this.embedding = embedding; + this.distance = distance; + } + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getCountry() { + return country; + } + + public void setCountry(String country) { + this.country = country; + } + + public String getDescription() { + return description; + } + + public float[] getEmbedding() { + return embedding; + } + + public void setEmbedding(float[] embedding) { + this.embedding = embedding; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getDistance() { + return distance; + } + + public void setDistance(String distance) { + this.distance = distance; + } + + @Override + public String toString() { + return "WithVector{" + "id=" + id + ", country='" + country + '\'' + ", description='" + description + '\'' + + ", distance='" + distance + '\'' + ", embedding=" + Arrays.toString(embedding) + '}'; + } + } + + interface WithDescription { + String getDescription(); + } + + static class DescriptionDto { + + private final String description; + + public DescriptionDto(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } + } + + interface VectorSearchRepository extends JpaRepository { + + List findAllByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance); + + @Query(""" + SELECT w FROM org.springframework.data.jpa.repository.AbstractVectorIntegrationTests$WithVector w + WHERE w.country = ?1 + AND cosine_distance(w.embedding, :embedding) <= :distance + ORDER BY cosine_distance(w.embedding, :embedding) asc""") + List findAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance); + + @Query(""" + SELECT w, cosine_distance(w.embedding, :embedding) as distance FROM org.springframework.data.jpa.repository.AbstractVectorIntegrationTests$WithVector w + WHERE w.country = ?1 + AND cosine_distance(w.embedding, :embedding) <= :distance + ORDER BY distance asc""") + SearchResults searchAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding, + Score distance); + + @Query(""" + SELECT w, cosine_distance(w.embedding, :embedding) as distance FROM org.springframework.data.jpa.repository.AbstractVectorIntegrationTests$WithVector w + WHERE w.country = ?1 + AND cosine_distance(w.embedding, :embedding) <= :distance + ORDER BY distance asc""") + SearchResults searchAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding, + float distance); + + SearchResults searchAllByCountryAndEmbeddingWithin(String country, Vector embedding, + Range distance); + + SearchResults searchTop5ByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance); + + SearchResults searchTop5ByCountryAndEmbeddingWithinOrderByDistance(String country, Vector embedding, + Score distance); + + SearchResults searchInterfaceProjectionByCountryAndEmbeddingWithin(String country, + Vector embedding, Score distance); + + SearchResults searchDtoByCountryAndEmbeddingWithin(String country, Vector embedding, + Score distance); + + SearchResults searchDynamicByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance, + Class projection); + + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AotUserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AotUserRepositoryTests.java new file mode 100644 index 0000000000..c35bafb0e3 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/AotUserRepositoryTests.java @@ -0,0 +1,110 @@ +/* + * Copyright 2012-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 jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import java.io.IOException; +import java.util.Collections; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.PropertiesFactoryBean; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.ImportResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.jpa.repository.aot.AotFragmentTestConfigurationSupport; +import org.springframework.data.jpa.repository.sample.SampleConfig; +import org.springframework.data.jpa.repository.sample.SampleEvaluationContextExtension; +import org.springframework.data.jpa.repository.sample.UserRepository; +import org.springframework.data.jpa.repository.sample.UserRepositoryImpl; +import org.springframework.data.jpa.repository.support.DefaultJpaContext; +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.core.support.RepositoryComposition; +import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; +import org.springframework.data.spel.spi.EvaluationContextExtension; +import org.springframework.test.context.ContextConfiguration; + +/** + * Integration test for {@link UserRepository} using JavaConfig with mounted AOT-generated repository methods. + * + * @author Mark Paluch + */ +@ContextConfiguration(classes = AotUserRepositoryTests.Config.class, inheritLocations = false) +class AotUserRepositoryTests extends UserRepositoryTests { + + @Configuration + @ImportResource("classpath:/infrastructure.xml") + static class Config { + + @PersistenceContext EntityManager entityManager; + @Autowired ApplicationContext applicationContext; + + @Bean + public EvaluationContextExtension sampleEvaluationContextExtension() { + return new SampleEvaluationContextExtension(); + } + + @Bean + static AotFragmentTestConfigurationSupport aot() { + return new AotFragmentTestConfigurationSupport(UserRepository.class, SampleConfig.class, false, + UserRepositoryImpl.class); + } + + @Bean + public UserRepository userRepository(BeanFactory beanFactory) throws Exception { + + ExtensionAwareEvaluationContextProvider evaluationContextProvider = new ExtensionAwareEvaluationContextProvider( + applicationContext); + + JpaRepositoryFactoryBean factory = new JpaRepositoryFactoryBean<>( + UserRepository.class); + factory.setEntityManager(entityManager); + factory.setBeanFactory(applicationContext); + factory + .setCustomImplementation(new UserRepositoryImpl(new DefaultJpaContext(Collections.singleton(entityManager)))); + + factory.setRepositoryFragments(RepositoryComposition.RepositoryFragments.just(beanFactory.getBean("fragment"))); + + factory.setNamedQueries(namedQueries()); + factory.setEvaluationContextProvider(evaluationContextProvider); + factory.afterPropertiesSet(); + + return factory.getObject(); + } + + @Bean + public GreetingsFrom greetingsFrom() { + return new GreetingsFrom(); + } + + private NamedQueries namedQueries() throws IOException { + + PropertiesFactoryBean factory = new PropertiesFactoryBean(); + factory.setLocation(new ClassPathResource("META-INF/jpa-named-queries.properties")); + factory.afterPropertiesSet(); + + return new PropertiesBasedNamedQueries(factory.getObject()); + } + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CrudMethodMetadataUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CrudMethodMetadataUnitTests.java index 76c7e95662..69e7379d19 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CrudMethodMetadataUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CrudMethodMetadataUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -17,9 +17,6 @@ import static org.mockito.Mockito.*; -import java.util.Collections; -import java.util.Map; - import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.LockModeType; @@ -28,6 +25,9 @@ import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.metamodel.Metamodel; +import java.util.Collections; +import java.util.Map; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomAbstractPersistableIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomAbstractPersistableIntegrationTests.java index 46353534a1..a5ac1bd413 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomAbstractPersistableIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomAbstractPersistableIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomEclipseLinkJpaVendorAdapter.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomEclipseLinkJpaVendorAdapter.java index 124fff7adb..6919ba545d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomEclipseLinkJpaVendorAdapter.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomEclipseLinkJpaVendorAdapter.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomHsqlHibernateJpaVendorAdaptor.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomHsqlHibernateJpaVendorAdaptor.java deleted file mode 100644 index 2957ab2e87..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomHsqlHibernateJpaVendorAdaptor.java +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2012-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; - -import org.hibernate.dialect.HSQLDialect; -import org.springframework.orm.jpa.vendor.Database; -import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; - -/** - * Fix for missing type declarations for HSQL. - * - * @see https://www.codesmell.org/blog/2008/12/hibernate-hsql-native-queries-and-booleans/ - * @author Oliver Gierke - * @deprecated since 3.0 without replacement as it's not needed anymore. - */ -@Deprecated -public class CustomHsqlHibernateJpaVendorAdaptor extends HibernateJpaVendorAdapter { - - @Override - protected Class determineDatabaseDialectClass(Database database) { - return super.determineDatabaseDialectClass(database); - } - - /** - * @deprecated since 3.0 without replacement as it's not needed anymore. - */ - @Deprecated - public static class CustomHsqlDialect extends HSQLDialect {} -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomerRepositoryProjectionTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomerRepositoryProjectionTests.java new file mode 100644 index 0000000000..73d6a6bb54 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/CustomerRepositoryProjectionTests.java @@ -0,0 +1,66 @@ +/* + * 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.*; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +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.Country; +import org.springframework.data.jpa.domain.sample.Customer; +import org.springframework.data.jpa.repository.sample.CustomerRepository; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.Transactional; + +/** + * Integration test for executing projecting query methods. + * + * @author Mark Paluch + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(locations = "classpath:config/namespace-application-context-h2.xml") +@Transactional +class CustomerRepositoryProjectionTests { + + @Autowired CustomerRepository repository; + + @AfterEach + void clearUp() { + repository.deleteAll(); + } + + @Test + void returnsCountries() { + + Customer customer = new Customer(); + customer.setId(42L); + customer.setCountry(Country.of("DE")); + customer.setName("someone"); + + repository.saveAndFlush(customer); + + List countries = repository.findCountries(); + + assertThat(countries).hasSize(1); + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkEntityGraphRepositoryMethodsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkEntityGraphRepositoryMethodsIntegrationTests.java index e0d21dc716..74820a58a2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkEntityGraphRepositoryMethodsIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkEntityGraphRepositoryMethodsIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java index 6eb1d40dda..ad61aa2f55 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkNamespaceUserRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -15,12 +15,13 @@ */ package org.springframework.data.jpa.repository; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import jakarta.persistence.Query; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; + import org.springframework.data.jpa.repository.sample.UserRepository; import org.springframework.test.context.ContextConfiguration; @@ -75,7 +76,7 @@ void queryProvidesCorrectNumberOfParametersForNativeQuery() { @Disabled @Override @Test // DATAJPA-980 - void supportsProjectionsWithNativeQueries() {} + void supportsInterfaceProjectionsWithNativeQueries() {} /** * Ignored until https://bugs.eclipse.org/bugs/show_bug.cgi?id=525319 is fixed. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkParentRepositoryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkParentRepositoryIntegrationTests.java index 06d1575fd5..796e14be8a 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkParentRepositoryIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkParentRepositoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkQueryByExampleIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkQueryByExampleIntegrationTests.java index afea59666d..1f0e5e1300 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkQueryByExampleIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkQueryByExampleIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkRepositoryWithCompositeKeyIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkRepositoryWithCompositeKeyIntegrationTests.java index 82ca0c59a9..a2f1bbb5db 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkRepositoryWithCompositeKeyIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkRepositoryWithCompositeKeyIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkStoredProcedureIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkStoredProcedureIntegrationTests.java index e3ff3d5c07..71a583ebc1 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkStoredProcedureIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkStoredProcedureIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. 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 99343adb99..9ad2fe8f3c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -36,4 +36,8 @@ void executesNotInQueryCorrectly() {} @Override void executesInKeywordForPageCorrectly() {} + @Disabled + @Override + void shouldProjectWithKeysetScrolling() {} + } 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/EclipseLinkUserRepositoryProjectionTests.java similarity index 70% 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/EclipseLinkUserRepositoryProjectionTests.java index 5cf9bc4917..d4d6e2a14f 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/EclipseLinkUserRepositoryProjectionTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * Copyright 2011-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. @@ -18,10 +18,15 @@ import org.junit.jupiter.api.Disabled; import org.springframework.test.context.ContextConfiguration; -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaParentRepositoryIntegrationTests extends ParentRepositoryIntegrationTests { +/** + * @author Oliver Gierke + * @author Greg Turnquist + */ +@ContextConfiguration("classpath:eclipselink-h2.xml") +class EclipseLinkUserRepositoryProjectionTests extends UserRepositoryProjectionTests { - @Override @Disabled - void testWithJoin() {} + @Override + void rawMapProjectionWithEntityAndAggregatedValue() {} + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipselinkRepositoryWithCompositeKeyTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipselinkRepositoryWithCompositeKeyTests.java new file mode 100644 index 0000000000..5eb94caf89 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipselinkRepositoryWithCompositeKeyTests.java @@ -0,0 +1,45 @@ +/* + * 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; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import org.springframework.context.annotation.ImportResource; +import org.springframework.test.context.ContextConfiguration; + +/** + * Testcase to run {@link RepositoryWithCompositeKeyTests} integration tests on top of EclipseLink. + * + * @author Mark Paluch + */ +@ContextConfiguration +class EclipselinkRepositoryWithCompositeKeyTests extends RepositoryWithCompositeKeyTests { + + @ImportResource({ "classpath:infrastructure.xml", "classpath:eclipselink.xml" }) + static class TestConfig extends RepositoryWithIdClassKeyTests.Config {} + + @Override + @Test + @Disabled("Eclipselink doesn't support batch delete by id with IdClass") + void shouldSupportDeleteAllByIdInBatchWithIdClass() {} + + @Override + @Test + @Disabled("Eclipselink doesn't support derived identities with IdClass") + void shouldSupportSavingEntitiesWithCompositeKeyClassesWithEmbeddedIdsAndDerivedIdentities() {} + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EntityGraphRepositoryMethodsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EntityGraphRepositoryMethodsIntegrationTests.java index 1086577a21..7191b85a4d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EntityGraphRepositoryMethodsIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EntityGraphRepositoryMethodsIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EntityWithAssignedIdIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EntityWithAssignedIdIntegrationTests.java index 10a84ed886..05e6714895 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EntityWithAssignedIdIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EntityWithAssignedIdIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2024 the original author or authors. + * Copyright 2019-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/GreetingsFrom.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/GreetingsFrom.java index 247944ccd0..3e31f95ffb 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/GreetingsFrom.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/GreetingsFrom.java @@ -1,5 +1,5 @@ /* - * Copyright 2024 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/HibernateCurrentTenantIdentifierResolver.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/HibernateCurrentTenantIdentifierResolver.java new file mode 100644 index 0000000000..c6670bcc95 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/HibernateCurrentTenantIdentifierResolver.java @@ -0,0 +1,51 @@ +/* + * 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; + +import java.util.Optional; + +import org.hibernate.context.spi.CurrentTenantIdentifierResolver; +import org.jspecify.annotations.Nullable; + +/** + * {@code CurrentTenantIdentifierResolver} instance for testing. + * + * @author Ariel Morelli Andres + */ +public class HibernateCurrentTenantIdentifierResolver implements CurrentTenantIdentifierResolver { + + private static final ThreadLocal<@Nullable String> CURRENT_TENANT_IDENTIFIER = new ThreadLocal<>(); + + static void setTenantIdentifier(String tenantIdentifier) { + CURRENT_TENANT_IDENTIFIER.set(tenantIdentifier); + } + + static void removeTenantIdentifier() { + CURRENT_TENANT_IDENTIFIER.remove(); + } + + @Override + public String resolveCurrentTenantIdentifier() { + return Optional.ofNullable(CURRENT_TENANT_IDENTIFIER.get()) + .orElseThrow(() -> new IllegalArgumentException("Could not resolve current tenant identifier")); + } + + @Override + public boolean validateExistingCurrentSessions() { + return true; + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/HibernateMultitenancyTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/HibernateMultitenancyTests.java new file mode 100644 index 0000000000..28ebcd1765 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/HibernateMultitenancyTests.java @@ -0,0 +1,94 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assumptions.*; + +import jakarta.persistence.EntityManager; + +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +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.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.ImportResource; +import org.springframework.data.jpa.domain.sample.Role; +import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.jpa.repository.sample.RoleRepository; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.Transactional; + +/** + * Tests for repositories that use multi-tenancy. This tests verifies that repositories can be created an injected + * despite not having a tenant available at creation time. + * + * @author Ariel Morelli Andres + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration +class HibernateMultitenancyTests { + + @Autowired RoleRepository roleRepository; + @Autowired EntityManager em; + + @AfterEach + void tearDown() { + HibernateCurrentTenantIdentifierResolver.removeTenantIdentifier(); + } + + @Test // GH-3425 + void testPersistenceProviderFromFactoryWithoutTenant() { + + PersistenceProvider provider = PersistenceProvider.fromEntityManager(em); + + assumeThat(provider).isEqualTo(PersistenceProvider.HIBERNATE); + } + + @Test // GH-3425 + void testRepositoryWithTenant() { + + HibernateCurrentTenantIdentifierResolver.setTenantIdentifier("tenant-id"); + + assertThatNoException().isThrownBy(() -> roleRepository.findAll()); + } + + @Test // GH-3425 + void testRepositoryWithoutTenantFails() { + assertThatThrownBy(() -> roleRepository.findAll()).isInstanceOf(RuntimeException.class); + } + + @Transactional + List insertAndQuery() { + roleRepository.save(new Role("DRUMMER")); + roleRepository.flush(); + return roleRepository.findAll(); + } + + @ImportResource("classpath:multitenancy-test.xml") + @Configuration + @EnableJpaRepositories(basePackageClasses = HibernateRepositoryTests.class, considerNestedRepositories = true, + includeFilters = @ComponentScan.Filter(classes = { RoleRepository.class }, type = FilterType.ASSIGNABLE_TYPE)) + static class TestConfig {} + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/HibernateRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/HibernateRepositoryTests.java new file mode 100644 index 0000000000..5f965f203e --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/HibernateRepositoryTests.java @@ -0,0 +1,123 @@ +/* + * 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; + +import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assumptions.*; + +import jakarta.persistence.EntityManager; + +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.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.ImportResource; +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.PersistenceProvider; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.jpa.repository.sample.RoleRepository; +import org.springframework.data.jpa.repository.sample.UserRepository; +import org.springframework.data.repository.CrudRepository; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.Transactional; + +/** + * Hibernate-specific repository tests. + * + * @author Mark Paluch + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration() +@Transactional +class HibernateRepositoryTests { + + @Autowired UserRepository userRepository; + @Autowired RoleRepository roleRepository; + @Autowired CteUserRepository cteUserRepository; + @Autowired EntityManager em; + + PersistenceProvider provider; + User dave; + User carter; + User oliver; + Role drummer; + Role guitarist; + Role singer; + + @BeforeEach + void setUp() { + provider = PersistenceProvider.fromEntityManager(em); + + assumeThat(provider).isEqualTo(PersistenceProvider.HIBERNATE); + roleRepository.deleteAll(); + userRepository.deleteAll(); + + drummer = roleRepository.save(new Role("DRUMMER")); + guitarist = roleRepository.save(new Role("GUITARIST")); + singer = roleRepository.save(new Role("SINGER")); + + dave = userRepository.save(new User("Dave", "Matthews", "dave@dmband.com", singer)); + carter = userRepository.save(new User("Carter", "Beauford", "carter@dmband.com", singer, drummer)); + oliver = userRepository.save(new User("Oliver August", "Matthews", "oliver@dmband.com")); + } + + @Test // GH-3726 + void testQueryWithCTE() { + + Page result = cteUserRepository.findWithCTE(PageRequest.of(0, 1)); + assertThat(result.getTotalElements()).isEqualTo(3); + } + + @ImportResource({ "classpath:infrastructure.xml" }) + @Configuration + @EnableJpaRepositories(basePackageClasses = HibernateRepositoryTests.class, considerNestedRepositories = true, + includeFilters = @ComponentScan.Filter( + classes = { CteUserRepository.class, UserRepository.class, RoleRepository.class }, + type = FilterType.ASSIGNABLE_TYPE)) + static class TestConfig {} + + interface CteUserRepository extends CrudRepository { + + /* + WITH entities AS ( + SELECT + e.id as id, + e.number as number + FROM TestEntity e + ) + SELECT new com.example.demo.Result('X', c.id, c.number) + FROM entities c + */ + + @Query(""" + WITH cte_select AS (select u.firstname as firstname, u.lastname as lastname from User u) + SELECT new org.springframework.data.jpa.repository.UserExcerptDto(c.firstname, c.lastname) + FROM cte_select c + """) + Page findWithCTE(Pageable page); + + } + +} 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 b1aeb29c3a..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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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,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 factory = new JpaRepositoryFactoryBean<>( diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/MappedTypeRepositoryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/MappedTypeRepositoryIntegrationTests.java index 1a33ab4bc2..42428a19f2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/MappedTypeRepositoryIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/MappedTypeRepositoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/NamespaceUserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/NamespaceUserRepositoryTests.java index a37117e848..5c99719d12 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/NamespaceUserRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/NamespaceUserRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/ORMInfrastructureTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/ORMInfrastructureTests.java index 98bd534a78..5cbb6a96bb 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/ORMInfrastructureTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/ORMInfrastructureTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. 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 cfe023d9c6..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaNamespaceUserRepositoryTests.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2008-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; - -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 criteriaQuery = builder.createQuery(User.class); - Root root = criteriaQuery.from(User.class); - ParameterExpression parameter = builder.parameter(Collection.class); - criteriaQuery.where(root. get("id").in(parameter)); - - TypedQuery query = em.createQuery(criteriaQuery); - query.setParameter(parameter, Arrays.asList(1, 2)); - - List 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/OpenJpaStoredProcedureIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaStoredProcedureIntegrationTests.java deleted file mode 100644 index 993cb32a04..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaStoredProcedureIntegrationTests.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2015-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; - -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/OracleVectorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OracleVectorIntegrationTests.java new file mode 100644 index 0000000000..5b1d8779c6 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OracleVectorIntegrationTests.java @@ -0,0 +1,95 @@ +/* + * 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 java.net.URL; +import java.util.List; + +import org.hibernate.dialect.OracleDialect; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import org.testcontainers.oracle.OracleContainer; +import org.testcontainers.utility.MountableFile; + +/** + * Testcase to verify Vector Search work with Oracle. + * + * @author Mark Paluch + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = OracleVectorIntegrationTests.Config.class) +class OracleVectorIntegrationTests extends AbstractVectorIntegrationTests { + + @EnableJpaRepositories(considerNestedRepositories = true, + includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = VectorSearchRepository.class)) + @EnableTransactionManagement + static class Config extends TestcontainerConfigSupport { + + public Config() { + super(OracleDialect.class, new ClassPathResource("scripts/oracle-vector.sql")); + } + + @Override + protected String getSchemaAction() { + return "none"; + } + + @Override + protected PersistenceManagedTypes getManagedTypes() { + return new PersistenceManagedTypes() { + @Override + public List getManagedClassNames() { + return List.of(WithVector.class.getName()); + } + + @Override + public List getManagedPackages() { + return List.of(); + } + + @Override + public @Nullable URL getPersistenceUnitRootUrl() { + return null; + } + + }; + } + + @SuppressWarnings("resource") + @Bean(initMethod = "start", destroyMethod = "start") + public OracleContainer container() { + + return new OracleContainer("gvenzl/oracle-free:23-slim") // + .withReuse(true) + .withCopyFileToContainer(MountableFile.forClasspathResource("/scripts/oracle-vector-initialize.sql"), + "/container-entrypoint-initdb.d/initialize.sql"); + } + + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/ParentRepositoryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/ParentRepositoryIntegrationTests.java index 6b85895950..7b53e6669b 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/ParentRepositoryIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/ParentRepositoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/PgVectorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/PgVectorIntegrationTests.java new file mode 100644 index 0000000000..2427e1f930 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/PgVectorIntegrationTests.java @@ -0,0 +1,92 @@ +/* + * 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 java.net.URL; +import java.util.List; + +import org.hibernate.dialect.PostgreSQLDialect; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.extension.ExtendWith; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import org.testcontainers.containers.PostgreSQLContainer; + +/** + * Testcase to verify Vector Search work with Postgres (PGvector). + * + * @author Mark Paluch + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = PgVectorIntegrationTests.Config.class) +class PgVectorIntegrationTests extends AbstractVectorIntegrationTests { + + @EnableJpaRepositories(considerNestedRepositories = true, + includeFilters = @Filter(type = FilterType.ASSIGNABLE_TYPE, classes = VectorSearchRepository.class)) + @EnableTransactionManagement + static class Config extends TestcontainerConfigSupport { + + public Config() { + super(PostgreSQLDialect.class, new ClassPathResource("scripts/pgvector.sql")); + } + + @Override + protected String getSchemaAction() { + return "none"; + } + + @Override + protected PersistenceManagedTypes getManagedTypes() { + return new PersistenceManagedTypes() { + @Override + public List getManagedClassNames() { + return List.of(WithVector.class.getName()); + } + + @Override + public List getManagedPackages() { + return List.of(); + } + + @Override + public @Nullable URL getPersistenceUnitRootUrl() { + return null; + } + }; + + } + + @SuppressWarnings("resource") + @Bean(initMethod = "start", destroyMethod = "start") + public PostgreSQLContainer container() { + + return new PostgreSQLContainer<>("pgvector/pgvector:pg17") // + .withUsername("postgres").withReuse(true); + } + + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/QueryByExampleIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/QueryByExampleIntegrationTests.java index a5302d9506..fa9de5e26f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/QueryByExampleIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/QueryByExampleIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -15,7 +15,7 @@ */ package org.springframework.data.jpa.repository; -import static org.assertj.core.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; import jakarta.persistence.EntityManager; import jakarta.persistence.criteria.CriteriaBuilder; @@ -23,21 +23,27 @@ import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; +import java.util.List; + import org.junit.jupiter.api.AfterEach; 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.data.domain.Example; +import org.springframework.data.domain.ExampleMatcher; import org.springframework.data.jpa.convert.QueryByExamplePredicateBuilder; import org.springframework.data.jpa.domain.sample.Role; +import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.repository.sample.RoleRepository; +import org.springframework.data.jpa.repository.sample.UserRepository; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.transaction.annotation.Transactional; /** * @author Greg Turnquist + * @author Christoph Strobl * @since 3.0 */ @ExtendWith(SpringExtension.class) @@ -45,7 +51,8 @@ @Transactional class QueryByExampleIntegrationTests { - @Autowired RoleRepository repository; + @Autowired RoleRepository roleRepository; + @Autowired UserRepository userRepository; @Autowired EntityManager em; private Role drummer; @@ -55,14 +62,14 @@ class QueryByExampleIntegrationTests { @BeforeEach void setUp() { - drummer = repository.save(new Role("drummer")); - guitarist = repository.save(new Role("guitarist")); - singer = repository.save(new Role("singer")); + drummer = roleRepository.save(new Role("drummer")); + guitarist = roleRepository.save(new Role("guitarist")); + singer = roleRepository.save(new Role("singer")); } @AfterEach void clearUp() { - repository.deleteAll(); + roleRepository.deleteAll(); } @Test // GH-2283 @@ -81,6 +88,39 @@ void queryByExampleWithNoPredicatesShouldHaveNoWhereClause() { // then assertThat(predicate).isNull(); - assertThat(repository.findAll(example)).containsExactlyInAnyOrder(drummer, guitarist, singer); + assertThat(roleRepository.findAll(example)).containsExactlyInAnyOrder(drummer, guitarist, singer); + } + + @Test // GH-3763 + void usesAnyMatchOnJoins() { + + User manager = new User("mighty", "super user", "msu@u.io"); + + userRepository.save(manager); + + User dave = new User(); + dave.setFirstname("dave"); + dave.setLastname("matthews"); + dave.setEmailAddress("d@dmb.com"); + dave.addRole(singer); + + User carter = new User(); + carter.setFirstname("carter"); + carter.setLastname("beaufort"); + carter.setEmailAddress("c@dmb.com"); + carter.addRole(drummer); + carter.addRole(singer); + carter.setManager(manager); + + userRepository.saveAllAndFlush(List.of(dave, carter)); + + User probe = new User(); + probe.setLastname(dave.getLastname()); + probe.setManager(manager); + + Example example = Example.of(probe, + ExampleMatcher.matchingAny().withIgnorePaths("id", "createdAt", "age", "active", "emailAddress", + "secondaryEmailAddress", "colleagues", "address", "binaryData", "attributes", "dateOfBirth")); + assertThat(userRepository.findAll(example)).containsExactly(dave, carter); } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RedeclaringRepositoryMethodsTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RedeclaringRepositoryMethodsTests.java index b8b1f54511..c4f5c4e30d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RedeclaringRepositoryMethodsTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RedeclaringRepositoryMethodsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithCompositeKeyTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithCompositeKeyTests.java index 64b51acb1c..1a56a77875 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithCompositeKeyTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithCompositeKeyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -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; @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -36,8 +37,12 @@ import org.springframework.data.jpa.domain.sample.IdClassExampleEmployeePK; import org.springframework.data.jpa.domain.sample.QEmbeddedIdExampleEmployee; import org.springframework.data.jpa.domain.sample.QIdClassExampleEmployee; +import org.springframework.data.jpa.domain.sample.ReferencingEmbeddedIdExampleEmployee; +import org.springframework.data.jpa.domain.sample.ReferencingIdClassExampleEmployee; import org.springframework.data.jpa.repository.sample.EmployeeRepositoryWithEmbeddedId; import org.springframework.data.jpa.repository.sample.EmployeeRepositoryWithIdClass; +import org.springframework.data.jpa.repository.sample.ReferencingEmployeeRepositoryWithEmbeddedIdRepository; +import org.springframework.data.jpa.repository.sample.ReferencingEmployeeRepositoryWithIdClassRepository; import org.springframework.data.jpa.repository.sample.SampleConfig; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -52,6 +57,7 @@ * @author Ernst-Jan van der Laan * @author Krzysztof Krason * @author Aleksei Elin + * @author Jakub Soltys */ @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = SampleConfig.class) @@ -60,6 +66,10 @@ class RepositoryWithCompositeKeyTests { @Autowired EmployeeRepositoryWithIdClass employeeRepositoryWithIdClass; @Autowired EmployeeRepositoryWithEmbeddedId employeeRepositoryWithEmbeddedId; + @Autowired + ReferencingEmployeeRepositoryWithEmbeddedIdRepository referencingEmployeeRepositoryWithEmbeddedIdRepository; + @Autowired + ReferencingEmployeeRepositoryWithIdClassRepository referencingEmployeeRepositoryWithIdClassRepository; @Autowired EntityManager em; /** @@ -115,6 +125,24 @@ void shouldSupportSavingEntitiesWithCompositeKeyClassesWithEmbeddedIdsAndDerived assertThat(persistedEmp.getDepartment().getName()).isEqualTo(dep.getName()); } + @Test // GH-3929 + void shouldReturnIdentifiers() { + + EmbeddedIdExampleDepartment dep = new EmbeddedIdExampleDepartment(); + dep.setName("TestDepartment"); + dep.setDepartmentId(-1L); + + EmbeddedIdExampleEmployee emp = new EmbeddedIdExampleEmployee(); + emp.setDepartment(dep); + emp.setEmployeePk(new EmbeddedIdExampleEmployeePK(1L, 2L)); + + emp = employeeRepositoryWithEmbeddedId.save(emp); + + List identifiers = employeeRepositoryWithEmbeddedId.findIdentifiers(); + + assertThat(identifiers).hasSize(1).contains(emp.getEmployeePk()); + } + @Test // DATAJPA-472, DATAJPA-912 void shouldSupportFindAllWithPageableAndEntityWithIdClass() { @@ -341,4 +369,148 @@ void shouldExecuteExistsQueryForEntitiesWithCompoundIdClassKeys() { assertThat(employeeRepositoryWithIdClass.existsByName(emp1.getName())).isTrue(); assertThat(employeeRepositoryWithIdClass.existsByName("Walter")).isFalse(); } + + @Test // GH-3349 + void findByRelationshipPartialEmbeddedId() { + + EmbeddedIdExampleDepartment dep1 = new EmbeddedIdExampleDepartment(); + dep1.setDepartmentId(1L); + dep1.setName("Dep1"); + + EmbeddedIdExampleDepartment dep2 = new EmbeddedIdExampleDepartment(); + dep2.setDepartmentId(2L); + dep2.setName("Dep2"); + + EmbeddedIdExampleEmployee emp1 = new EmbeddedIdExampleEmployee(); + emp1.setEmployeePk(new EmbeddedIdExampleEmployeePK(1L, 1L)); + emp1.setDepartment(dep1); + emp1 = employeeRepositoryWithEmbeddedId.save(emp1); + + EmbeddedIdExampleEmployee emp2 = new EmbeddedIdExampleEmployee(); + emp2.setEmployeePk(new EmbeddedIdExampleEmployeePK(1L, 2L)); + emp2.setDepartment(dep2); + emp2 = employeeRepositoryWithEmbeddedId.save(emp2); + + ReferencingEmbeddedIdExampleEmployee refEmp1 = new ReferencingEmbeddedIdExampleEmployee(); + refEmp1.setId(1L); + refEmp1.setEmployee(emp1); + refEmp1 = referencingEmployeeRepositoryWithEmbeddedIdRepository.save(refEmp1); + + ReferencingEmbeddedIdExampleEmployee refEmp2 = new ReferencingEmbeddedIdExampleEmployee(); + refEmp2.setId(2L); + refEmp2.setEmployee(emp2); + refEmp2 = referencingEmployeeRepositoryWithEmbeddedIdRepository.save(refEmp2); + + List result = referencingEmployeeRepositoryWithEmbeddedIdRepository.findByEmployee_EmployeePk_employeeId(1L); + + assertThat(result).isNotNull(); + assertThat(result).hasSize(2); + assertThat(result).containsOnly(refEmp1, refEmp2); + + List result2 = referencingEmployeeRepositoryWithEmbeddedIdRepository.findByEmployee_EmployeePk_DepartmentId(2L); + + assertThat(result2).isNotNull(); + assertThat(result2).hasSize(1); + assertThat(result2).containsOnly(refEmp2); + } + + @Test // GH-3349 + void findByRelationshipPartialIdClass() { + + IdClassExampleDepartment dep1 = new IdClassExampleDepartment(); + dep1.setDepartmentId(1L); + dep1.setName("Dep1"); + + IdClassExampleDepartment dep2 = new IdClassExampleDepartment(); + dep2.setDepartmentId(2L); + dep2.setName("Dep2"); + + IdClassExampleEmployee emp1 = new IdClassExampleEmployee(); + emp1.setEmpId(1L); + emp1.setDepartment(dep1); + emp1 = employeeRepositoryWithIdClass.save(emp1); + + IdClassExampleEmployee emp2 = new IdClassExampleEmployee(); + emp2.setEmpId(1L); + emp2.setDepartment(dep2); + emp2 = employeeRepositoryWithIdClass.save(emp2); + + ReferencingIdClassExampleEmployee refEmp1 = new ReferencingIdClassExampleEmployee(); + refEmp1.setId(1L); + refEmp1.setEmployee(emp1); + refEmp1 = referencingEmployeeRepositoryWithIdClassRepository.save(refEmp1); + + ReferencingIdClassExampleEmployee refEmp2 = new ReferencingIdClassExampleEmployee(); + refEmp2.setId(2L); + refEmp2.setEmployee(emp2); + refEmp2 = referencingEmployeeRepositoryWithIdClassRepository.save(refEmp2); + + List result = referencingEmployeeRepositoryWithIdClassRepository.findByEmployee_EmpId(1L); + + assertThat(result).isNotNull(); + assertThat(result).hasSize(2); + assertThat(result).containsOnly(refEmp1, refEmp2); + + List result2 = referencingEmployeeRepositoryWithIdClassRepository.findByEmployee_Department_DepartmentId(2L); + + assertThat(result2).isNotNull(); + assertThat(result2).hasSize(1); + assertThat(result2).containsOnly(refEmp2); + } + + @Test + void findByPartialRelationshipIdClass() { + + IdClassExampleDepartment dep1 = new IdClassExampleDepartment(); + dep1.setDepartmentId(1L); + dep1.setName("Dep1"); + + IdClassExampleDepartment dep2 = new IdClassExampleDepartment(); + dep2.setDepartmentId(2L); + dep2.setName("Dep2"); + + IdClassExampleEmployee emp1 = new IdClassExampleEmployee(); + emp1.setEmpId(1L); + emp1.setDepartment(dep1); + emp1 = employeeRepositoryWithIdClass.save(emp1); + + IdClassExampleEmployee emp2 = new IdClassExampleEmployee(); + emp2.setEmpId(1L); + emp2.setDepartment(dep2); + employeeRepositoryWithIdClass.save(emp2); + + List result = employeeRepositoryWithIdClass.findAllByDepartment_DepartmentId(1L); + + assertThat(result).isNotNull(); + assertThat(result).hasSize(1); + assertThat(result).containsOnly(emp1); + } + + @Test + void findByPartialDirectIdClass() { + + IdClassExampleDepartment dep1 = new IdClassExampleDepartment(); + dep1.setDepartmentId(1L); + dep1.setName("Dep1"); + + IdClassExampleDepartment dep2 = new IdClassExampleDepartment(); + dep2.setDepartmentId(2L); + dep2.setName("Dep2"); + + IdClassExampleEmployee emp1 = new IdClassExampleEmployee(); + emp1.setEmpId(1L); + emp1.setDepartment(dep1); + emp1 = employeeRepositoryWithIdClass.save(emp1); + + IdClassExampleEmployee emp2 = new IdClassExampleEmployee(); + emp2.setEmpId(1L); + emp2.setDepartment(dep2); + emp2 = employeeRepositoryWithIdClass.save(emp2); + + List result = employeeRepositoryWithIdClass.findAllByEmpId(1L); + + assertThat(result).isNotNull(); + assertThat(result).hasSize(2); + assertThat(result).containsOnly(emp1, emp2); + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithIdClassKeyTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithIdClassKeyTests.java index b0cbec6b44..5cc0f45ce0 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithIdClassKeyTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithIdClassKeyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RoleRepositoryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RoleRepositoryIntegrationTests.java index 2a8f849823..5fc3573d64 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RoleRepositoryIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RoleRepositoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SPR8954Tests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SPR8954Tests.java index f731ca2c66..f2a0042a32 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SPR8954Tests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SPR8954Tests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. 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 bc11790d8e..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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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,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/StoredProcedureIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/StoredProcedureIntegrationTests.java index 93615637f5..8cc377e059 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/StoredProcedureIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/StoredProcedureIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserExcerptDto.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserExcerptDto.java new file mode 100644 index 0000000000..dad70a9a2c --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserExcerptDto.java @@ -0,0 +1,48 @@ +/* + * 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; + +/** + * Hibernate is still a bit picky on records so let's use a class, just in case. + * + * @author Christoph Strobl + */ +public class UserExcerptDto { + + private String firstname; + private String lastname; + + public UserExcerptDto(String firstname, String lastname) { + this.firstname = firstname; + this.lastname = lastname; + } + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } +} 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 6a8c06789d..2c73a64803 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -27,6 +27,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.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Limit; @@ -42,9 +43,7 @@ import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.sample.RoleRepository; import org.springframework.data.jpa.repository.sample.UserRepository; -import org.springframework.data.jpa.repository.sample.UserRepository.IdOnly; import org.springframework.data.jpa.repository.sample.UserRepository.NameOnly; -import org.springframework.data.jpa.repository.sample.UserRepository.RolesAndFirstname; import org.springframework.data.repository.query.QueryLookupStrategy; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -58,6 +57,7 @@ * @author Krzysztof Krason * @author Greg Turnquist * @author Mark Paluch + * @author Christoph Strobl * @see QueryLookupStrategy */ @ExtendWith(SpringExtension.class) @@ -246,9 +246,9 @@ void executesQueryWithLimitAndScrollPosition() { @Test // GH-3409 void executesWindowQueryWithPageable() { - Window first = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(0,1)); + Window first = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(0, 1)); - Window next = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(1,1)); + Window next = userRepository.findByLastnameOrderByFirstname("Matthews", PageRequest.of(1, 1)); assertThat(first).containsExactly(dave); assertThat(next).containsExactly(oliver); @@ -309,6 +309,20 @@ void escapingInLikeSpelsInThePresenceOfEscapedWildcards() { assertThat(userRepository.findContainingEscaped("att\\_")).containsExactly(withEscapedWildcard); } + @Test // GH-3619 + void propertyPlaceholderInQuery() { + + User extra = new User("extra", "Matt_ew", "extra"); + + userRepository.save(extra); + + System.setProperty("query.lastname", "%_ew"); + assertThat(userRepository.findWithPropertyPlaceholder()).containsOnly(extra); + + System.clearProperty("query.lastname"); + assertThat(userRepository.findWithPropertyPlaceholder()).isEmpty(); + } + @Test // DATAJPA-829 void translatesContainsToMemberOf() { @@ -326,29 +340,6 @@ void translatesNotContainsToNotMemberOf() { .containsExactlyInAnyOrder(dave, oliver); } - @Test // DATAJPA-974, GH-2815 - void executesQueryWithProjectionContainingReferenceToPluralAttribute() { - - List rolesAndFirstnameBy = userRepository.findRolesAndFirstnameBy(); - - assertThat(rolesAndFirstnameBy).isNotNull(); - - for (RolesAndFirstname rolesAndFirstname : rolesAndFirstnameBy) { - assertThat(rolesAndFirstname.getFirstname()).isNotNull(); - assertThat(rolesAndFirstname.getRoles()).isNotNull(); - } - } - - @Test // GH-2815 - void executesQueryWithProjectionThroughStringQuery() { - - List ids = userRepository.findIdOnly(); - - assertThat(ids).isNotNull(); - - assertThat(ids).extracting(IdOnly::getId).doesNotContainNull(); - } - @Test // DATAJPA-1023, DATACMNS-959 @Transactional(propagation = Propagation.NOT_SUPPORTED) void rejectsStreamExecutionIfNoSurroundingTransactionActive() { @@ -357,19 +348,30 @@ void rejectsStreamExecutionIfNoSurroundingTransactionActive() { .isThrownBy(() -> userRepository.findAllByCustomQueryAndStream()); } - @Test // DATAJPA-1334 - void executesNamedQueryWithConstructorExpression() { - userRepository.findByNamedQueryWithConstructorExpression(); + @Test // GH-3675 + void findBySimplePropertyUsingMixedNullNonNullArgument() { + + List result = userRepository.findUserByLastname(null); + assertThat(result).isEmpty(); + result = userRepository.findUserByLastname(carter.getLastname()); + assertThat(result).containsExactly(carter); } - @Test // DATAJPA-1713, GH-2008 - public void selectProjectionWithSubselect() { + @Test // GH-3675 + void findByNegatingSimplePropertyUsingMixedNullNonNullArgument() { + + List result = userRepository.findByLastnameNot(null); + assertThat(result).isNotEmpty(); + result = userRepository.findUserByLastname(carter.getLastname()); + assertThat(result).containsExactly(carter); + } - List dtos = userRepository.findProjectionBySubselect(); + @Test // GH-3857 + void shouldApplyParameterNames() { - assertThat(dtos).flatExtracting(UserRepository.NameOnly::getFirstname) // - .containsExactly("Dave", "Carter", "Oliver August"); - assertThat(dtos).flatExtracting(UserRepository.NameOnly::getLastname) // - .containsExactly("Matthews", "Beauford", "Matthews"); + assertThat(userRepository.findAnnotatedWithParameterNameQuery(oliver.getLastname())).hasSize(2); + assertThat(userRepository.findWithParameterNameByLastnameStartingWithOrLastnameEndingWith(oliver.getLastname(), + oliver.getLastname())).hasSize(2); } + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryProjectionTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryProjectionTests.java new file mode 100644 index 0000000000..8771939ac4 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryProjectionTests.java @@ -0,0 +1,286 @@ +/* + * 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.*; + +import jakarta.persistence.EntityManager; + +import java.util.List; +import java.util.Map; + +import org.assertj.core.data.Offset; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +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.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +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; +import org.springframework.data.jpa.repository.sample.RoleRepository; +import org.springframework.data.jpa.repository.sample.UserRepository; +import org.springframework.data.jpa.repository.sample.UserRepository.IdOnly; +import org.springframework.data.jpa.repository.sample.UserRepository.NameOnly; +import org.springframework.data.jpa.repository.sample.UserRepository.RolesAndFirstname; +import org.springframework.data.jpa.repository.sample.UserRepository.UserExcerpt; +import org.springframework.data.jpa.repository.sample.UserRepository.UserRoleCountDtoProjection; +import org.springframework.data.jpa.repository.sample.UserRepository.UserRoleCountInterfaceProjection; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.Transactional; + +/** + * Integration test for executing projecting query methods. + * + * @author Oliver Gierke + * @author Krzysztof Krason + * @author Greg Turnquist + * @author Mark Paluch + * @author Christoph Strobl + * @see QueryLookupStrategy + */ +@ExtendWith(SpringExtension.class) +@ContextConfiguration(locations = "classpath:config/namespace-application-context-h2.xml") +@Transactional +class UserRepositoryProjectionTests { + + @Autowired UserRepository userRepository; + @Autowired RoleRepository roleRepository; + @Autowired EntityManager em; + + PersistenceProvider provider; + + private User dave; + private User carter; + private User oliver; + private Role drummer; + private Role guitarist; + private Role singer; + + @BeforeEach + void setUp() { + + drummer = roleRepository.save(new Role("DRUMMER")); + guitarist = roleRepository.save(new Role("GUITARIST")); + singer = roleRepository.save(new Role("SINGER")); + + dave = userRepository.save(new User("Dave", "Matthews", "dave@dmband.com", singer)); + carter = userRepository.save(new User("Carter", "Beauford", "carter@dmband.com", singer, drummer)); + oliver = userRepository.save(new User("Oliver August", "Matthews", "oliver@dmband.com")); + + provider = PersistenceProvider.fromEntityManager(em); + } + + @AfterEach + void clearUp() { + + userRepository.deleteAll(); + roleRepository.deleteAll(); + } + + @Test // DATAJPA-974, GH-2815 + void executesQueryWithProjectionContainingReferenceToPluralAttribute() { + + List rolesAndFirstnameBy = userRepository.findRolesAndFirstnameBy(); + + assertThat(rolesAndFirstnameBy).isNotNull(); + + for (RolesAndFirstname rolesAndFirstname : rolesAndFirstnameBy) { + assertThat(rolesAndFirstname.getFirstname()).isNotNull(); + assertThat(rolesAndFirstname.getRoles()).isNotNull(); + } + } + + @Test // GH-2815 + void executesQueryWithProjectionThroughStringQuery() { + + List ids = userRepository.findIdOnly(); + + assertThat(ids).isNotNull(); + + assertThat(ids).extracting(IdOnly::getId).doesNotContainNull(); + } + + @Test // DATAJPA-1334 + void executesNamedQueryWithConstructorExpression() { + userRepository.findByNamedQueryWithConstructorExpression(); + } + + @Test // DATAJPA-1713, GH-2008 + void selectProjectionWithSubselect() { + + List dtos = userRepository.findProjectionBySubselect(); + + assertThat(dtos).flatExtracting(NameOnly::getFirstname) // + .containsExactly("Dave", "Carter", "Oliver August"); + assertThat(dtos).flatExtracting(NameOnly::getLastname) // + .containsExactly("Matthews", "Beauford", "Matthews"); + } + + @Test // GH-3076 + void dtoProjectionShouldApplyConstructorExpressionRewriting() { + + List dtos = userRepository.findRecordProjection(); + + assertThat(dtos).flatExtracting(UserExcerpt::firstname) // + .contains("Dave", "Carter", "Oliver August"); + + dtos = userRepository.findRecordProjectionWithFunctions(); + + assertThat(dtos).flatExtracting(UserExcerpt::lastname) // + .contains("matthews", "beauford"); + } + + @Test // GH-3895 + void stringProjectionShouldNotApplyConstructorExpressionRewriting() { + + List names = userRepository.findStringProjection(); + + assertThat(names) // + .contains("Dave", "Carter", "Oliver August"); + } + + @Test // GH-3895 + void objectArrayProjectionShouldNotApplyConstructorExpressionRewriting() { + + List names = userRepository.findObjectArrayProjectionWithFunctions(); + + assertThat(names) // + .contains(new String[] { "Dave", "matthews" }); + } + + @Test // GH-3076 + void dtoMultiselectProjectionShouldApplyConstructorExpressionRewriting() { + + List dtos = userRepository.findMultiselectRecordProjection(); + + assertThat(dtos).flatExtracting(UserExcerpt::firstname) // + .contains("Dave", "Carter", "Oliver August"); + } + + @Test // GH-3895 + void dtoMultiselectProjectionShouldApplyConstructorExpressionRewritingForJoin() { + + dave.setAddress(new Address("US", "Albuquerque", "some street", "12345")); + + List dtos = userRepository.findAddressProjection(); + + assertThat(dtos).flatExtracting(UserRepository.AddressDto::city) // + .contains("Albuquerque"); + } + + @Test // GH-3076 + void dynamicDtoProjection() { + + List dtos = userRepository.findRecordProjection(UserExcerpt.class); + + assertThat(dtos).flatExtracting(UserExcerpt::firstname) // + .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() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat(userRepository.dtoProjectionEntityAndAggregatedValue()).allSatisfy(projection -> { + assertThat(projection.user()).isIn(musicians.values()); + assertThat(projection.roleCount()).isCloseTo(musicians.get(projection.user().getFirstname()).getRoles().size(), + Offset.offset(0L)); + }); + } + + @Test // GH-3076 + void interfaceProjectionWithEntityAndAggregatedValue() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat(userRepository.interfaceProjectionEntityAndAggregatedValue()).allSatisfy(projection -> { + assertThat(projection.getUser()).isIn(musicians.values()); + assertThat(projection.getRoleCount()) + .isCloseTo(musicians.get(projection.getUser().getFirstname()).getRoles().size(), Offset.offset(0L)); + }); + } + + @Test // GH-3076 + void rawMapProjectionWithEntityAndAggregatedValue() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat(userRepository.rawMapProjectionEntityAndAggregatedValue()).allSatisfy(projection -> { + assertThat(projection.get("user")).isIn(musicians.values()); + assertThat(projection).containsKey("roleCount"); + }); + } + + @Test // GH-3076 + void dtoProjectionWithEntityAndAggregatedValueWithPageable() { + + Map musicians = Map.of(carter.getFirstname(), carter, dave.getFirstname(), dave, + oliver.getFirstname(), oliver); + + assertThat( + userRepository.dtoProjectionEntityAndAggregatedValue(PageRequest.of(0, 10).withSort(Sort.by("firstname")))) + .allSatisfy(projection -> { + assertThat(projection.user()).isIn(musicians.values()); + assertThat(projection.roleCount()) + .isCloseTo(musicians.get(projection.user().getFirstname()).getRoles().size(), Offset.offset(0L)); + }); + } + + @ParameterizedTest // GH-3076 + @ValueSource(classes = { UserRoleCountDtoProjection.class, UserRoleCountInterfaceProjection.class }) + void dynamicProjectionWithEntityAndAggregated(Class resultType) { + + assertThat(userRepository.findMultiselectRecordDynamicProjection(resultType)).hasSize(3) + .hasOnlyElementsOfType(resultType); + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryStoredProcedureIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryStoredProcedureIntegrationTests.java index de57832f7a..1f158fa4fd 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryStoredProcedureIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryStoredProcedureIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. 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 d549077eec..239ece0c1e 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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; @@ -36,18 +34,20 @@ import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Function; import java.util.stream.IntStream; import java.util.stream.Stream; 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; @@ -60,15 +60,21 @@ 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; import org.springframework.data.jpa.domain.sample.SpecialUser; import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.jpa.repository.sample.NameOnlyRecord; import org.springframework.data.jpa.repository.sample.SampleEvaluationContextExtension.SampleSecurityContextHolder; 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.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.transaction.annotation.Transactional; @@ -450,7 +456,6 @@ void testOverwritingFinder() { @Test void testUsesQueryAnnotation() { - assertThat(repository.findByAnnotatedQuery("gierke@synyx.de")).isNull(); } @@ -465,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 @@ -495,11 +500,11 @@ void throwsExceptionForUnderSpecifiedSingleEntitySpecification() { void executesCombinedSpecificationsCorrectly() { flushTestUsers(); - Specification spec1 = userHasFirstname("Oliver").or(userHasLastname("Arrasz")); + PredicateSpecification spec1 = userHasFirstname("Oliver").or(userHasLastname("Arrasz")); List users1 = repository.findAll(spec1); assertThat(users1).hasSize(2); - Specification spec2 = Specification.anyOf( // + PredicateSpecification spec2 = PredicateSpecification.anyOf( // userHasFirstname("Oliver"), // userHasLastname("Arrasz")); List users2 = repository.findAll(spec2); @@ -512,7 +517,8 @@ void executesCombinedSpecificationsCorrectly() { void executesNegatingSpecificationCorrectly() { flushTestUsers(); - Specification spec = not(userHasFirstname("Oliver")).and(userHasLastname("Arrasz")); + PredicateSpecification spec = PredicateSpecification.not(userHasFirstname("Oliver")) + .and(userHasLastname("Arrasz")); assertThat(repository.findAll(spec)).containsOnly(secondUser); } @@ -521,18 +527,18 @@ void executesNegatingSpecificationCorrectly() { void executesCombinedSpecificationsWithPageableCorrectly() { flushTestUsers(); - Specification spec1 = userHasFirstname("Oliver").or(userHasLastname("Arrasz")); + PredicateSpecification spec1 = userHasFirstname("Oliver").or(userHasLastname("Arrasz")); - Page users1 = repository.findAll(spec1, PageRequest.of(0, 1)); + Page users1 = repository.findAll(Specification.where(spec1), PageRequest.of(0, 1)); assertThat(users1.getSize()).isOne(); assertThat(users1.hasPrevious()).isFalse(); assertThat(users1.getTotalElements()).isEqualTo(2L); - Specification spec2 = Specification.anyOf( // + PredicateSpecification spec2 = PredicateSpecification.anyOf( // userHasFirstname("Oliver"), // userHasLastname("Arrasz")); - Page users2 = repository.findAll(spec2, PageRequest.of(0, 1)); + Page users2 = repository.findAll(Specification.where(spec2), PageRequest.of(0, 1)); assertThat(users2.getSize()).isOne(); assertThat(users2.hasPrevious()).isFalse(); assertThat(users2.getTotalElements()).isEqualTo(2L); @@ -587,7 +593,7 @@ void executesSimpleNotCorrectly() { void returnsSameListIfNoSpecGiven() { flushTestUsers(); - assertSameElements(repository.findAll(), repository.findAll((Specification) null)); + assertSameElements(repository.findAll(), repository.findAll(PredicateSpecification.unrestricted())); } @Test @@ -603,15 +609,41 @@ void returnsSamePageIfNoSpecGiven() { Pageable pageable = PageRequest.of(0, 1); flushTestUsers(); - assertThat(repository.findAll((Specification) null, pageable)).isEqualTo(repository.findAll(pageable)); + assertThat(repository.findAll(Specification.unrestricted(), pageable)).isEqualTo(repository.findAll(pageable)); + } + + @Test // GH-3521 + void updateSpecificationUpdatesMarriedEntities() { + + flushTestUsers(); + + UpdateSpecification updateLastname = UpdateSpecification. 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 predicateSpecificationRemovesAll() { + + flushTestUsers(); + + repository.delete(DeleteSpecification.unrestricted()); + + assertThat(repository.count()).isEqualTo(0L); } @Test // GH-2796 - void removesAllIfSpecificationIsNull() { + void deleteSpecificationRemovesAll() { flushTestUsers(); - repository.delete((Specification) null); + repository.delete(DeleteSpecification.unrestricted()); assertThat(repository.count()).isEqualTo(0L); } @@ -771,9 +803,6 @@ void executesFinderWithFalseKeywordCorrectly() { assertThat(repository.findByActiveFalse()).containsOnly(firstUser); } - /** - * Ignored until the query declaration is supported by OpenJPA. - */ @Test void executesAnnotatedCollectionMethodCorrectly() { @@ -1407,6 +1436,57 @@ void scrollByPredicateKeysetBackward() { assertThat(previousWindow.hasNext()).isFalse(); } + @Test // GH-2327 + void scrollByPredicateKeysetWithInterfaceProjection() { + + User jane1 = new User("Jane", "Doe", "jane@doe1.com"); + User jane2 = new User("Jane", "Doe", "jane@doe2.com"); + User john1 = new User("John", "Doe", "john@doe1.com"); + User john2 = new User("John", "Doe", "john@doe2.com"); + + repository.saveAllAndFlush(Arrays.asList(john1, john2, jane1, jane2)); + + Window firstWindow = repository.findBy(QUser.user.firstname.startsWith("J"), + q -> q.limit(1).sortBy(Sort.by("firstname", "emailAddress")).as(UserProjectionInterfaceBased.class) + .scroll(ScrollPosition.keyset())); + + assertThat(firstWindow.getContent()).extracting(UserProjectionInterfaceBased::getFirstname) + .containsOnly(jane1.getFirstname()); + assertThat(firstWindow.hasNext()).isTrue(); + + Window nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"), + q -> q.limit(2).sortBy(Sort.by("firstname", "emailAddress")).as(UserProjectionInterfaceBased.class) + .scroll(firstWindow.positionAt(0))); + + assertThat(nextWindow.getContent()).extracting(UserProjectionInterfaceBased::getFirstname) + .containsExactly(jane2.getFirstname(), john1.getFirstname()); + assertThat(nextWindow.hasNext()).isTrue(); + } + + @Test // GH-2327 + void scrollByPredicateKeysetWithDtoProjection() { + + User jane1 = new User("Jane", "Doe", "jane@doe1.com"); + User jane2 = new User("Jane", "Doe", "jane@doe2.com"); + User john1 = new User("John", "Doe", "john@doe1.com"); + User john2 = new User("John", "Doe", "john@doe2.com"); + + repository.saveAllAndFlush(Arrays.asList(john1, john2, jane1, jane2)); + + Window firstWindow = repository.findBy(QUser.user.firstname.startsWith("J"), + q -> q.limit(1).sortBy(Sort.by("firstname", "emailAddress")).as(UserDto.class).scroll(ScrollPosition.keyset())); + + assertThat(firstWindow.getContent()).extracting(UserDto::firstname).containsOnly(jane1.getFirstname()); + assertThat(firstWindow.hasNext()).isTrue(); + + Window nextWindow = repository.findBy(QUser.user.firstname.startsWith("J"), q -> q.limit(2) + .sortBy(Sort.by("firstname", "emailAddress")).as(UserDto.class).scroll(firstWindow.positionAt(0))); + + assertThat(nextWindow.getContent()).extracting(UserDto::firstname).containsExactly(jane2.getFirstname(), + john1.getFirstname()); + assertThat(nextWindow.hasNext()).isTrue(); + } + @Test // GH-2878 void scrollByPartTreeKeysetBackward() { @@ -1502,6 +1582,38 @@ void deleteByShouldReturnListOfDeletedElementsWhenRetunTypeIsCollectionLike() { assertThat(result).containsOnly(firstUser); } + @Test // GH-3995 + void deleteOneByShouldReturnDeletedElement() { + + assertThat(repository.deleteOneByLastname(firstUser.getLastname())).isNull(); + + flushTestUsers(); + + User result = repository.deleteOneByLastname(firstUser.getLastname()); + assertThat(result).isEqualTo(firstUser); + } + + @Test // GH-3995 + void deleteOneOptionalByShouldReturnDeletedElement() { + + flushTestUsers(); + + Optional result = repository.deleteOneOptionalByLastname(firstUser.getLastname()); + assertThat(result).contains(firstUser); + } + + @Test // GH-3995 + void deleteOneShouldFailWhenMatchingMultipleResults() { + + firstUser.setLastname("foo"); + secondUser.setLastname("foo"); + firstUser = repository.save(firstUser); + secondUser = repository.save(secondUser); + + assertThatExceptionOfType(IncorrectResultSizeDataAccessException.class) + .isThrownBy(() -> repository.deleteOneByLastname(firstUser.getLastname())); + } + @Test // DATAJPA-460 void deleteByShouldRemoveElementsMatchingDerivedQuery() { @@ -1511,12 +1623,21 @@ void deleteByShouldRemoveElementsMatchingDerivedQuery() { assertThat(repository.countByLastname(firstUser.getLastname())).isZero(); } - @Test // DATAJPA-460 + @Test // DATAJPA-460, GH-4015 void deleteByShouldReturnNumberOfEntitiesRemovedIfReturnTypeIsLong() { flushTestUsers(); assertThat(repository.removeByLastname(firstUser.getLastname())).isOne(); + assertThat(repository.removeOneByLastname(secondUser.getLastname())).isOne(); + } + + @Test // GH-4015 + void deleteByShouldReturnNumberOfEntitiesRemovedIfReturnTypeIsInt() { + + flushTestUsers(); + + assertThat(repository.removeOneMoreByLastname(secondUser.getLastname())).isOne(); } @Test // DATAJPA-460 @@ -1535,11 +1656,7 @@ void deleteByShouldReturnEmptyListInCaseNoEntityHasBeenRemovedAndReturnTypeIsCol assertThat(repository.deleteByLastname("dorfuaeB")).isEmpty(); } - /** - * @see OPENJPA-2484 - */ @Test // DATAJPA-505 - @Disabled void findBinaryDataByIdJpaQl() throws Exception { byte[] data = "Woho!!".getBytes("UTF-8"); @@ -2352,6 +2469,14 @@ void findByFluentExampleWithSorting() { assertThat(users).containsExactly(thirdUser, firstUser, fourthUser); } + @Test // GH-3294 + void findByFluentFailsReturningFluentQuery() { + + User prototype = new User(); + assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) + .isThrownBy(() -> repository.findBy(of(prototype), Function.identity())); + } + @Test // GH-2294 void findByFluentExampleFirstValue() { @@ -2422,6 +2547,24 @@ void findByFluentExamplePage() { assertThat(page1.getContent()).containsExactly(fourthUser); } + @Test // GH-3762 + void findByFluentExamplePageSortOverride() { + + flushTestUsers(); + + User prototype = new User(); + prototype.setFirstname("v"); + + Example userProbe = of(prototype, matching().withIgnorePaths("age", "createdAt", "active") + .withMatcher("firstname", GenericPropertyMatcher::contains)); + + Page page = repository.findBy(userProbe, // + q -> q.sortBy(Sort.by("firstname")).page(PageRequest.of(0, 2, Sort.by(DESC, "firstname")))); + + assertThat(page.getContent()).containsExactly(fourthUser, firstUser); + assertThat(repository.findAll(page.nextPageable())).containsExactly(secondUser, thirdUser); + } + @Test // GH-2294 void findByFluentExampleWithInterfaceBasedProjection() { @@ -2449,13 +2592,13 @@ void findByFluentExampleWithInterfaceBasedProjectionUsingSpEL() { prototype.setFirstname("v"); List users = repository.findBy( - of(prototype, - matching().withIgnorePaths("age", "createdAt", "active").withMatcher("firstname", - GenericPropertyMatcher::contains)), // - q -> q.as(UserProjectionUsingSpEL.class).all()); + of(prototype, + matching().withIgnorePaths("age", "createdAt", "active").withMatcher("firstname", + GenericPropertyMatcher::contains)), // + q -> q.as(UserProjectionUsingSpEL.class).all()); assertThat(users).extracting(UserProjectionUsingSpEL::hello) - .contains(new GreetingsFrom().groot(firstUser.getFirstname())); + .contains(new GreetingsFrom().groot(firstUser.getFirstname())); } @Test // GH-2294 @@ -2554,40 +2697,6 @@ void findByFluentExampleWithSortedInterfaceBasedProjection() { .containsExactlyInAnyOrder(thirdUser.getFirstname(), firstUser.getFirstname(), fourthUser.getFirstname()); } - @Test // GH-2294 - void fluentExamplesWithClassBasedDtosNotYetSupported() { - - class UserDto { - String firstname; - - public UserDto() {} - - public String getFirstname() { - return this.firstname; - } - - public void setFirstname(String firstname) { - this.firstname = firstname; - } - - public String toString() { - return "UserDto(firstname=" + this.getFirstname() + ")"; - } - } - - assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> { - - User prototype = new User(); - prototype.setFirstname("v"); - - repository.findBy( - of(prototype, - matching().withIgnorePaths("age", "createdAt", "active").withMatcher("firstname", - GenericPropertyMatcher::contains)), // - q -> q.as(UserDto.class).sortBy(Sort.by("firstname")).all()); - }); - } - @Test // GH-2294 void countByFluentExample() { @@ -2677,7 +2786,63 @@ void findByFluentSpecificationPage() { assertThat(page1.getContent()).containsExactly(fourthUser); } - @Test // GH-2274 + @Test // GH-3762 + void findByFluentSpecificationSortOverridePage() { + + flushTestUsers(); + + Page page = repository.findBy(userHasFirstnameLike("v"), + q -> q.sortBy(Sort.by("firstname")).page(PageRequest.of(0, 2, Sort.by(DESC, "firstname")))); + + assertThat(page.getContent()).containsExactly(fourthUser, firstUser); + assertThat(repository.findAll(page.nextPageable())).containsExactly(secondUser, thirdUser); + + Slice slice = repository.findBy(userHasFirstnameLike("v"), + q -> q.sortBy(Sort.by("firstname")).slice(PageRequest.of(0, 2, Sort.by(DESC, "firstname")))); + + assertThat(slice.getContent()).containsExactly(fourthUser, firstUser); + assertThat(repository.findAll(slice.nextPageable())).containsExactly(secondUser, thirdUser); + } + + @Test // GH-2274, 3762 + void findByFluentSpecificationSlice() { + + flushTestUsers(); + + Slice slice = repository.findBy(userHasFirstnameLike("v"), + q -> q.sortBy(Sort.by(DESC, "firstname")).slice(PageRequest.of(0, 2, Sort.by("firstname")))); + + assertThat(slice).isNotInstanceOf(Page.class); + assertThat(slice.getContent()).containsExactly(thirdUser, firstUser); + assertThat(slice.hasNext()).isTrue(); + + slice = repository.findBy(userHasFirstnameLike("v"), + q -> q.sortBy(Sort.by("firstname")).slice(PageRequest.of(0, 3, Sort.by("firstname")))); + + assertThat(slice).isNotInstanceOf(Page.class); + assertThat(slice).hasSize(3); + assertThat(slice.hasNext()).isFalse(); + } + + @Test // GH-3727 + void findByFluentSpecificationPageCustomCountSpec() { + + flushTestUsers(); + + Page page0 = repository.findBy(userHasFirstnameLike("v"), + q -> q.sortBy(Sort.by("firstname")).page(PageRequest.of(0, 2), (root, query, criteriaBuilder) -> null)); + + assertThat(page0.getContent()).containsExactly(thirdUser, firstUser); + assertThat(page0.getTotalElements()).isEqualTo(4L); + + page0 = repository.findBy(userHasFirstnameLike("v"), + q -> q.sortBy(Sort.by("firstname")).page(PageRequest.of(0, 2))); + + assertThat(page0.getContent()).containsExactly(thirdUser, firstUser); + assertThat(page0.getTotalElements()).isEqualTo(3L); + } + + @Test // GH-2274, GH-3716 void findByFluentSpecificationWithInterfaceBasedProjection() { flushTestUsers(); @@ -2687,6 +2852,31 @@ void findByFluentSpecificationWithInterfaceBasedProjection() { assertThat(users).extracting(UserProjectionInterfaceBased::getFirstname) .containsExactlyInAnyOrder(firstUser.getFirstname(), thirdUser.getFirstname(), fourthUser.getFirstname()); + + assertThat(users).extracting(UserProjectionInterfaceBased::getLastname).doesNotContainNull(); + + users = repository.findBy(userHasFirstnameLike("v"), + q -> q.as(UserProjectionInterfaceBased.class).project("firstname").all()); + + assertThat(users).extracting(UserProjectionInterfaceBased::getFirstname).doesNotContainNull(); + assertThat(users).extracting(UserProjectionInterfaceBased::getLastname).containsExactly(null, null, null); + } + + @Test // GH-2327 + void findByFluentSpecificationWithDtoProjection() { + + flushTestUsers(); + + List users = repository.findBy(userHasFirstnameLike("v"), q -> q.as(UserDto.class).all()); + + assertThat(users).extracting(UserDto::firstname).containsExactlyInAnyOrder(firstUser.getFirstname(), + thirdUser.getFirstname(), fourthUser.getFirstname()); + + // project is a no-op for DTO projections as we must use the constructor as input properties + users = repository.findBy(userHasFirstnameLike("v"), q -> q.as(UserDto.class).project("lastname").all()); + + assertThat(users).extracting(UserDto::firstname).containsExactlyInAnyOrder(firstUser.getFirstname(), + thirdUser.getFirstname(), fourthUser.getFirstname()); } @Test // GH-2274 @@ -2710,6 +2900,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() { @@ -2799,32 +3000,6 @@ void findByFluentSpecificationWithSortedInterfaceBasedProjection() { .containsExactlyInAnyOrder(thirdUser.getFirstname(), firstUser.getFirstname(), fourthUser.getFirstname()); } - @Test // GH-2274 - void fluentSpecificationWithClassBasedDtosNotYetSupported() { - - class UserDto { - String firstname; - - public UserDto() {} - - public String getFirstname() { - return this.firstname; - } - - public void setFirstname(String firstname) { - this.firstname = firstname; - } - - public String toString() { - return "UserDto(firstname=" + this.getFirstname() + ")"; - } - } - - assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> { - repository.findBy(userHasFirstnameLike("v"), q -> q.as(UserDto.class).sortBy(Sort.by("firstname")).all()); - }); - } - @Test // GH-2274 void countByFluentSpecification() { @@ -2887,6 +3062,29 @@ void existsByExampleNegative() { assertThat(exists).isFalse(); } + @Test // GH-3476 + void findAllUsingUnpagePageableWithSort() { + + flushTestUsers(); + + Sort sort = Sort.by(DESC, "firstname"); + Page firstPage = repository.findAll(PageRequest.of(0, 10, sort)); + Page secondPage = repository.findAll(Pageable.unpaged(sort)); + assertThat(firstPage.getContent()).containsExactlyElementsOf(secondPage.getContent()); + } + + @Test // GH-3476 + void derivedFinderUsingUnpagedPageableWithSort() { + + flushTestUsers(); + + List sortedUsers = new ArrayList<>(List.of(firstUser, secondUser, thirdUser, fourthUser)); + sortedUsers.sort(Comparator.comparing(User::getEmailAddress)); + + assertThat(repository.findByEmailAddressLike("%@%", Pageable.unpaged(Sort.by(Direction.ASC, "lastname")))) + .containsExactlyElementsOf(sortedUsers); + } + @Test // DATAJPA-905 void executesPagedSpecificationSettingAnOrder() { @@ -2932,6 +3130,16 @@ void dynamicProjectionReturningList() { assertThat(users).hasSize(1); } + @Test // GH-2327 + void dynamicOpenProjectionReturningList() { + + flushTestUsers(); + + List users = repository.findAsListByFirstnameLike("%O%", UserProjectionUsingSpEL.class); + + assertThat(users).hasSize(1); + } + @Test // DATAJPA-1179 void duplicateSpelsWorkAsIntended() { @@ -2943,7 +3151,7 @@ void duplicateSpelsWorkAsIntended() { } @Test // DATAJPA-980 - void supportsProjectionsWithNativeQueries() { + void supportsInterfaceProjectionsWithNativeQueries() { flushTestUsers(); @@ -2955,6 +3163,20 @@ void supportsProjectionsWithNativeQueries() { assertThat(result.getLastname()).isEqualTo(user.getLastname()); } + @Test // GH-2757 + @DisabledOnHibernate("6.2") + void supportsRecordsWithNativeQueries() { + + flushTestUsers(); + + User user = repository.findAll().get(0); + + NameOnlyRecord result = repository.findRecordProjectionByNativeQuery(user.getId()); + + assertThat(result.firstname()).isEqualTo(user.getFirstname()); + assertThat(result.lastname()).isEqualTo(user.getLastname()); + } + @Test // DATAJPA-1248 void supportsProjectionsWithNativeQueriesAndCamelCaseProperty() { @@ -2971,6 +3193,19 @@ void supportsProjectionsWithNativeQueriesAndCamelCaseProperty() { .isNotNull(); } + @Test // GH-3155 + void supportsResultSetMappingWithNativeQueries() { + + flushTestUsers(); + + User user = repository.findAll().get(0); + + User.EmailDto result = repository.findEmailDtoByNativeQuery(user.getId()); + + assertThat(result.getOne()).isEqualTo(user.getEmailAddress()); + assertThat(result.getTwo()).isEqualTo(user.getSecondaryEmailAddress()); + } + @Test // GH-3462 void supportsProjectionsWithNativeQueriesAndUnderscoresColumnNameToCamelCaseProperty() { @@ -3007,22 +3242,68 @@ void handlesColonsFollowedByIntegerInStringLiteral() { assertThat(users).extracting(User::getId).containsExactly(expected.getId()); } - @Disabled("ORDER BY CASE appears to be a Hibernate-only feature") - @Test // DATAJPA-1233 + @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 spec = userHasFirstname("Oliver").or(userHasLastname("Matthews")); + + List 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() { - // repository.findAllOrderedBySpecialNameSingleParam("Oliver", PageRequest.of(2, 3)); + + flushTestUsers(); + + Page result = repository.findAllOrderedByNamedParam("Oliver", PageRequest.of(0, 3)); + + assertThat(result.getContent()).containsExactly(firstUser, fourthUser, thirdUser); + assertThat(result.getTotalElements()).isEqualTo(4); + + result = repository.findAllOrderedByIndexedParam("Oliver", PageRequest.of(0, 3)); + + assertThat(result.getContent()).containsExactly(firstUser, fourthUser, thirdUser); + assertThat(result.getTotalElements()).isEqualTo(4); } - @Disabled("ORDER BY CASE appears to be a Hibernate-only feature") - @Test // DATAJPA-1233 + @Test // DATAJPA-1233, GH-3756 void handlesCountQueriesWithLessParametersMoreThanOne() { - // repository.findAllOrderedBySpecialNameMultipleParams("Oliver", "x", PageRequest.of(2, 3)); - } - @Disabled("ORDER BY CASE appears to be a Hibernate-only feature") - @Test // DATAJPA-1233 - void handlesCountQueriesWithLessParametersMoreThanOneIndexed() { - // repository.findAllOrderedBySpecialNameMultipleParamsIndexed("x", "Oliver", PageRequest.of(2, 3)); + flushTestUsers(); + + Page result = repository.findAllOrderedBySpecialNameMultipleParams("Oliver", "x", PageRequest.of(0, 3)); + + assertThat(result.getContent()).containsExactly(firstUser, fourthUser, thirdUser); + assertThat(result.getTotalElements()).isEqualTo(4); + + result = repository.findAllOrderedBySpecialNameMultipleParamsIndexed("x", "Oliver", PageRequest.of(0, 3)); + + assertThat(result.getContent()).containsExactly(firstUser, fourthUser, thirdUser); + assertThat(result.getTotalElements()).isEqualTo(4); } // DATAJPA-928 @@ -3142,6 +3423,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 result = repository.findByAttributesIgnoreCaseIn(new HashSet<>(asList("cOOl", "hIP"))); assertThat(result).containsOnly(firstUser, secondUser); @@ -3210,8 +3500,8 @@ void existsWithSpec() { flushTestUsers(); - Specification minorSpec = userHasAgeLess(18); - Specification hundredYearsOld = userHasAgeLess(100); + PredicateSpecification minorSpec = userHasAgeLess(18); + PredicateSpecification hundredYearsOld = userHasAgeLess(100); assertThat(repository.exists(minorSpec)).isFalse(); assertThat(repository.exists(hundredYearsOld)).isTrue(); @@ -3236,7 +3526,7 @@ void deleteWithSpec() { flushTestUsers(); - Specification usersWithEInTheirName = userHasFirstnameLike("e"); + PredicateSpecification usersWithEInTheirName = userHasFirstnameLike("e"); long initialCount = repository.count(); assertThat(repository.delete(usersWithEInTheirName)).isEqualTo(3L); @@ -3383,16 +3673,16 @@ private Page executeSpecWithSort(Sort sort) { flushTestUsers(); - Specification spec1 = userHasFirstname("Oliver").or(userHasLastname("Matthews")); + PredicateSpecification spec1 = userHasFirstname("Oliver").or(userHasLastname("Matthews")); - Page result1 = repository.findAll(spec1, PageRequest.of(0, 1, sort)); + Page result1 = repository.findAll(Specification.where(spec1), PageRequest.of(0, 1, sort)); assertThat(result1.getTotalElements()).isEqualTo(2L); - Specification spec2 = Specification.anyOf( // + PredicateSpecification spec2 = PredicateSpecification.anyOf( // userHasFirstname("Oliver"), // userHasLastname("Matthews")); - Page result2 = repository.findAll(spec2, PageRequest.of(0, 1, sort)); + Page result2 = repository.findAll(Specification.where(spec2), PageRequest.of(0, 1, sort)); assertThat(result2.getTotalElements()).isEqualTo(2L); assertThat(result1).containsExactlyElementsOf(result2); @@ -3402,6 +3692,13 @@ private Page executeSpecWithSort(Sort sort) { private interface UserProjectionInterfaceBased { String getFirstname(); + + @Nullable + String getLastname(); + } + + public record UserDto(Integer id, String firstname, String lastname, String emailAddress) { + } private interface UserProjectionUsingSpEL { 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..69fed19329 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/AotContributionIntegrationTests.java @@ -0,0 +1,81 @@ +/* + * 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.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.jpa.repository.config.InfrastructureConfig; + +/** + * 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.register(configurationClasses); + + ApplicationContextAotGenerator generator = new ApplicationContextAotGenerator(); + + TestGenerationContext generationContext = new TestGenerationContext(); + generator.processAheadOfTime(context, generationContext); + generationContext.writeGeneratedContent(); + 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..960b1a4410 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/AotFragmentTestConfigurationSupport.java @@ -0,0 +1,180 @@ +/* + * 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 java.lang.reflect.Method; +import java.lang.reflect.Proxy; +import java.util.Arrays; +import java.util.List; + +import org.mockito.Mockito; + +import org.springframework.aot.test.generate.TestGenerationContext; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.ListableBeanFactory; +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.config.RuntimeBeanReference; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.DefaultBeanNameGenerator; +import org.springframework.context.annotation.ImportResource; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.test.tools.TestCompiler; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.jpa.repository.sample.SampleConfig; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.config.AnnotationRepositoryConfigurationSource; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +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.core.support.RepositoryFragment; +import org.springframework.data.repository.query.QueryMethodValueEvaluationContextAccessor; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.util.ReflectionUtils; + +/** + * Test Configuration Support Class for generated AOT Repository Fragments based on a Repository Interface. + *

      + * 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") +public class AotFragmentTestConfigurationSupport implements BeanFactoryPostProcessor { + + private final Class repositoryInterface; + private final boolean registerFragmentFacade; + private final Class[] additionalFragments; + private final RepositoryConfigurationSource configSource; + + public AotFragmentTestConfigurationSupport(Class repositoryInterface) { + this(repositoryInterface, SampleConfig.class, true); + } + + public AotFragmentTestConfigurationSupport(Class repositoryInterface, Class configClass) { + this(repositoryInterface, configClass, true); + } + + public AotFragmentTestConfigurationSupport(Class repositoryInterface, Class configClass, + boolean registerFragmentFacade, Class... additionalFragments) { + this.repositoryInterface = repositoryInterface; + this.registerFragmentFacade = registerFragmentFacade; + this.additionalFragments = additionalFragments; + this.configSource = new AnnotationRepositoryConfigurationSource(AnnotationMetadata.introspect(configClass), + EnableJpaRepositories.class, new DefaultResourceLoader(), new StandardEnvironment(), + Mockito.mock(BeanDefinitionRegistry.class), DefaultBeanNameGenerator.INSTANCE); + } + + @Override + public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { + + TestGenerationContext generationContext = new TestGenerationContext(repositoryInterface); + RepositoryComposition composition = RepositoryComposition + .of((List) Arrays.stream(additionalFragments).map(RepositoryFragment::structural).toList()); + + TestJpaAotRepositoryContext repositoryContext = new TestJpaAotRepositoryContext<>(beanFactory, + repositoryInterface, composition, configSource); + + new JpaRepositoryContributor(repositoryContext).contribute(generationContext); + + AbstractBeanDefinition aotGeneratedRepository = BeanDefinitionBuilder + .genericBeanDefinition(repositoryInterface.getName() + "Impl__AotRepository") + .addConstructorArgValue(new RuntimeBeanReference(EntityManager.class)) + .addConstructorArgValue( + getCreationContext(repositoryContext, beanFactory.getBean(Environment.class), beanFactory)) + .getBeanDefinition(); + + generationContext.writeGeneratedContent(); + + TestCompiler.forSystem().withCompilerOptions("-parameters").with(generationContext).compile(compiled -> { + beanFactory.setBeanClassLoader(compiled.getClassLoader()); + ((BeanDefinitionRegistry) beanFactory).registerBeanDefinition("fragment", aotGeneratedRepository); + }); + + if (registerFragmentFacade) { + + 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; + }); + } + + private RepositoryFactoryBeanSupport.FragmentCreationContext getCreationContext( + TestJpaAotRepositoryContext repositoryContext, Environment environment, ListableBeanFactory beanFactory) { + + RepositoryFactoryBeanSupport.FragmentCreationContext creationContext = new RepositoryFactoryBeanSupport.FragmentCreationContext() { + @Override + public RepositoryMetadata getRepositoryMetadata() { + return repositoryContext.getRepositoryInformation(); + } + + @Override + public ValueExpressionDelegate getValueExpressionDelegate() { + + QueryMethodValueEvaluationContextAccessor accessor = new QueryMethodValueEvaluationContextAccessor(environment, + beanFactory); + return new ValueExpressionDelegate(accessor, ValueExpressionParser.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/JpaRepositoryContributorConfigurationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorConfigurationTests.java new file mode 100644 index 0000000000..c6b8a8ad19 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRepositoryContributorConfigurationTests.java @@ -0,0 +1,74 @@ +/* + * 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.aot.hint.RuntimeHints; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.UrlResource; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * Integration tests for the {@link UserRepository} AOT fragment. + * + * @author Mark Paluch + */ +class JpaRepositoryContributorConfigurationTests { + + @Configuration + static class JpaRepositoryContributorConfiguration extends AotFragmentTestConfigurationSupport { + public JpaRepositoryContributorConfiguration() { + super(UserRepository.class, MyConfiguration.class); + } + + @EnableJpaRepositories(escapeCharacter = 'ö', /* avoid creating repository instances */ includeFilters = { + @ComponentScan.Filter(value = EnableJpaRepositories.class) }) + static class MyConfiguration { + + } + } + + @Test // GH-3838 + void shouldConsiderConfiguration() throws IOException { + + AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(); + context.register(JpaRepositoryContributorConfiguration.class); + context.refreshForAotProcessing(new RuntimeHints()); + + String location = UserRepository.class.getPackageName().replace('.', '/') + "/" + + UserRepository.class.getSimpleName() + ".json"; + UrlResource resource = new UrlResource(context.getBeanFactory().getBeanClassLoader().getResource(location)); + + assertThat(resource).isNotNull(); + assertThat(resource.exists()).isTrue(); + + String json = resource.getContentAsString(StandardCharsets.UTF_8); + + assertThatJson(json).inPath("$.methods[?(@.name == 'streamByLastnameLike')].query").isArray().first().isObject() + .containsEntry("query", + "SELECT u FROM User u WHERE u.lastname LIKE :lastname ESCAPE 'ö'"); + } + +} 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 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 = 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 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 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 users = fragment.findTop2ByLastnameStartingWith("S"); + assertThat(users).hasSize(2); + } + + @Test // GH-3830 + void testSortedDerivedFinder() { + + List 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 users = fragment.findByLastnameStartingWith("S", Limit.of(2)); + assertThat(users).hasSize(2); + } + + @Test // GH-3830 + void testDerivedFinderWithSort() { + + List 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 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 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 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 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 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 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 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 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 users = fragment.findAnnotatedQueryByLastname("S", Limit.of(2)); + assertThat(users).hasSize(2); + } + + @Test // GH-3830 + void testAnnotatedFinderWithQueryAndSort() { + + List 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 = 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 = 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 = 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 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..1146e06306 --- /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 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/aot/JpaRuntimeHintsUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRuntimeHintsUnitTests.java index bce5b13fcd..9f7f6b5f0e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRuntimeHintsUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaRuntimeHintsUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaTypeFiltersUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaTypeFiltersUnitTests.java new file mode 100644 index 0000000000..0d919cc307 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/JpaTypeFiltersUnitTests.java @@ -0,0 +1,66 @@ +/* + * 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.LockOption; + +import org.eclipse.persistence.sessions.DatabaseSession; +import org.hibernate.Session; +import org.junit.jupiter.api.Test; + +import org.springframework.data.util.TypeCollector; + +/** + * Unit tests for {@link JpaTypeFilters}. + * + * @author Mark Paluch + */ +class JpaTypeFiltersUnitTests { + + @Test // GH-4014 + void shouldFilterUnreachableField() { + assertThat(TypeCollector.inspect(EnhancedEntity.class).list()).containsOnly(EnhancedEntity.class, Reachable.class); + } + + static class Unreachable { + + } + + static class Reachable { + + } + + static class EnhancedEntity { + + private Unreachable $$_hibernate_field; + private Reachable reachable; + private Session session; + private DatabaseSession databaseSession; + private LockOption lockOption; + + public EnhancedEntity(Session session, LockOption lockOption) { + this.session = session; + this.lockOption = lockOption; + } + + public void setSession(Session session) { + + } + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QueriesFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QueriesFactoryUnitTests.java new file mode 100644 index 0000000000..0881714f23 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QueriesFactoryUnitTests.java @@ -0,0 +1,96 @@ +/* + * Copyright 2025-present 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 static org.assertj.core.api.InstanceOfAssertFactories.*; +import static org.mockito.Mockito.*; + +import jakarta.persistence.Entity; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.Id; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.core.annotation.MergedAnnotations; +import org.springframework.data.jpa.provider.QueryExtractor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.query.JpaQueryMethod; +import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.config.AotRepositoryInformation; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; + +/** + * Unit tests for {@link QueriesFactory}. + * + * @author Christoph Strobl + * @author Mark Paluch + */ +class QueriesFactoryUnitTests { + + QueriesFactory factory; + + @BeforeEach + void setUp() { + + RepositoryConfigurationSource configSource = mock(RepositoryConfigurationSource.class); + EntityManagerFactory entityManagerFactory = mock(EntityManagerFactory.class); + + factory = new QueriesFactory(configSource, entityManagerFactory, this.getClass().getClassLoader()); + } + + @Test // GH-4029 + void stringQueryShouldResolveEntityNameFromJakartaAnnotationIfPresent() throws NoSuchMethodException { + + RepositoryInformation repositoryInformation = new AotRepositoryInformation( + AbstractRepositoryMetadata.getMetadata(MyRepository.class), MyRepository.class, Collections.emptyList()); + + Method method = MyRepository.class.getMethod("someFind"); + JpaQueryMethod queryMethod = new JpaQueryMethod(method, repositoryInformation, + new SpelAwareProxyProjectionFactory(), mock(QueryExtractor.class)); + + AotQueries generatedQueries = factory.createQueries(repositoryInformation, + queryMethod.getResultProcessor().getReturnedType(), QueryEnhancerSelector.DEFAULT_SELECTOR, + MergedAnnotations.from(method).get(Query.class), queryMethod); + + assertThat(generatedQueries.result()).asInstanceOf(type(StringAotQuery.class)) + .extracting(StringAotQuery::getQueryString).isEqualTo("select t from CustomNamed t"); + assertThat(generatedQueries.count()).asInstanceOf(type(StringAotQuery.class)) + .extracting(StringAotQuery::getQueryString).isEqualTo("select count(t) from CustomNamed t"); + } + + interface MyRepository extends Repository { + + @Query("select t from #{#entityName} t") + Collection someFind(); + } + + @Entity(name = "CustomNamed") + static class MyEntity { + + @Id Long id; + + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QuerydslUserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QuerydslUserRepository.java new file mode 100644 index 0000000000..6c551c482d --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QuerydslUserRepository.java @@ -0,0 +1,28 @@ +/* + * 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.domain.sample.User; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.repository.CrudRepository; + +interface QuerydslUserRepository extends CrudRepository, QuerydslPredicateExecutor { + + List findUserNoArgumentsBy(); + +} 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..bb107c5e2c --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/TestJpaAotRepositoryContext.java @@ -0,0 +1,99 @@ +/* + * 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.lang.annotation.Annotation; +import java.util.Set; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.BeanFactory; +import org.springframework.core.annotation.MergedAnnotation; +import org.springframework.data.aot.AotContext; +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.data.jpa.repository.support.JpaRepositoryFragmentsContributor; +import org.springframework.data.jpa.repository.support.SimpleJpaRepository; +import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.data.repository.config.AotRepositoryContextSupport; +import org.springframework.data.repository.config.AotRepositoryInformation; +import org.springframework.data.repository.config.RepositoryConfigurationSource; +import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.AnnotationRepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition; + +/** + * Test {@link AotRepositoryContext} implementation for JPA repositories. + * + * @author Christoph Strobl + */ +public class TestJpaAotRepositoryContext extends AotRepositoryContextSupport { + + private final AotRepositoryInformation repositoryInformation; + private final Class repositoryInterface; + private final RepositoryConfigurationSource configurationSource; + + public TestJpaAotRepositoryContext(BeanFactory beanFactory, Class repositoryInterface, + @Nullable RepositoryComposition composition, + RepositoryConfigurationSource configurationSource) { + super(AotContext.from(beanFactory)); + this.repositoryInterface = repositoryInterface; + this.configurationSource = configurationSource; + + RepositoryMetadata metadata = AnnotationRepositoryMetadata.getMetadata(repositoryInterface); + + RepositoryComposition.RepositoryFragments fragments = JpaRepositoryFragmentsContributor.DEFAULT.describe(metadata); + + this.repositoryInformation = new AotRepositoryInformation(metadata, SimpleJpaRepository.class, + composition.append(fragments).getFragments().stream().toList()); + } + + @Override + public String getModuleName() { + return "JPA"; + } + + @Override + public RepositoryConfigurationSource getConfigurationSource() { + return configurationSource; + } + + @Override + public Set> getIdentifyingAnnotations() { + return Set.of(Entity.class, MappedSuperclass.class); + } + + @Override + public RepositoryInformation getRepositoryInformation() { + return repositoryInformation; + } + + @Override + public Set> getResolvedAnnotations() { + return Set.of(); + } + + @Override + public Set> getResolvedTypes() { + return Set.of(User.class, SpecialUser.class, Role.class); + } + +} 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 d53d6bd531..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-2024 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 { + + List findUserNoArgumentsBy(); + + User findOneByEmailAddress(String emailAddress); + + Optional findOptionalOneByEmailAddress(String emailAddress); + + Long countUsersByLastname(String lastname); + + boolean existsUserByLastname(String lastname); + + List findByLastnameStartingWith(String lastname); + + List findTop2ByLastnameStartingWith(String lastname); + + List findByLastnameStartingWithOrderByEmailAddress(String lastname); + + List findByLastnameStartingWith(String lastname, Limit limit); + + List findByLastnameStartingWith(String lastname, Sort sort); + + List findByLastnameStartingWith(String lastname, Sort sort, Limit limit); + + List findByLastnameStartingWith(String lastname, Pageable page); + + Page findPageOfUsersByLastnameStartingWith(String lastname, Pageable page); + + Slice findSliceOfUserByLastnameStartingWith(String lastname, Pageable page); + + Stream 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 findAnnotatedQueryByLastname(String lastname); + + @Query("select u from User u where u.lastname like :lastname%") + List findAnnotatedQueryByLastnameParameter(String lastname); + + @Query("select u from User u where u.lastname like :lastname% or u.lastname like %:lastname") + List findAnnotatedLikeStartsEnds(String lastname); + + @Query(""" + select u + from User u + where u.lastname LIKE ?1%""") + List findAnnotatedMultilineQueryByLastname(String username); + + @Query("select u from User u where u.lastname like ?1%") + List findAnnotatedQueryByLastname(String lastname, Limit limit); + + @Query("select u from User u where u.lastname like ?1%") + List findAnnotatedQueryByLastname(String lastname, Sort sort); + + @Query("select u from User u where u.lastname like ?1%") + List findAnnotatedQueryByLastname(String lastname, Limit limit, Sort sort); + + // nasty parameter names + @Query("select u from User u where u.lastname like ?1%") + List findAnnotatedQueryByLastname(String query, Pageable queryString); + + @Query("select u from User u where u.lastname like ?1%") + Page findAnnotatedQueryPageOfUsersByLastname(String lastname, Pageable pageable); + + @Query("select u from User u where u.lastname like ?1% ORDER BY u.lastname") + Page findAnnotatedQueryPageWithStaticSort(String lastname, Pageable pageable); + + @Query("select u from User u where u.lastname like ?1%") + Slice 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 findAnnotatedWithParameterNameQuery(@Param("name") String lastname); + + List 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 findUserProjectionByLastnameStartingWith(String lastname); + + Page findUserProjectionByLastnameStartingWith(String lastname, Pageable page); + + Names findDtoByEmailAddress(String emailAddress); + + Page 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 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 findProjectedPageByEmailAddress(String emailAddress, Pageable page); + + Slice findProjectedSliceByEmailAddress(String lastname, Pageable page); + + Stream 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 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 findByNativeQueryWithPageable(Pageable pageable); + + // ------------------------------------------------------------------------- + // Named Queries + // ------------------------------------------------------------------------- + + User findByEmailAddress(String emailAddress); + + @Query(name = "User.findByEmailAddress") + Page findPagedByEmailAddress(Pageable pageable, String emailAddress); + + @Query(name = "User.findByEmailAddress", countQuery = "SELECT CoUnT(u) FROM User u WHERE u.emailAddress = ?1") + Page findPagedWithCountByEmailAddress(Pageable pageable, String emailAddress); + + @Query(name = "User.findByEmailAddress", countName = "User.findByEmailAddress.count-provided") + Page findPagedWithNamedCountByEmailAddress(Pageable pageable, String emailAddress); + + // ------------------------------------------------------------------------- + // Query Hints + // ------------------------------------------------------------------------- + + @QueryHints(value = { @QueryHint(name = "jakarta.persistence.cache.storeMode", value = "foo") }, forCounting = false) + List 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 findByEmailAddress(String emailAddress, Class 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 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/cdi/CdiExtensionIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/CdiExtensionIntegrationTests.java index 1d44e9bf86..e04c20790e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/CdiExtensionIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/CdiExtensionIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/EntityManagerFactoryProducer.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/EntityManagerFactoryProducer.java index a00c0d43b5..d293674d49 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/EntityManagerFactoryProducer.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/EntityManagerFactoryProducer.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/JpaQueryRewriterWithCdiIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/JpaQueryRewriterWithCdiIntegrationTests.java index 30d6a7b85d..92994cfef3 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/JpaQueryRewriterWithCdiIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/JpaQueryRewriterWithCdiIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryExtensionUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryExtensionUnitTests.java index 3b76bc9a05..7c8b6c8d94 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryExtensionUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/JpaRepositoryExtensionUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/Person.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/Person.java index a889375381..007de3b9c9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/Person.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/Person.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/PersonDB.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/PersonDB.java index c91f3f97bb..abb7b5e6fd 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/PersonDB.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/PersonDB.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/PersonRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/PersonRepository.java index 72b4956aba..eb8468db45 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/PersonRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/PersonRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedCdiConfiguration.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedCdiConfiguration.java index 67e387afbb..ad47eda044 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedCdiConfiguration.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedCdiConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepository.java index 2d41f76bf6..d43704a813 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepositoryBean.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepositoryBean.java index 779fa370ea..7a34f07fb4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepositoryBean.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepositoryBean.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepositoryCustom.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepositoryCustom.java index e4d49f78a0..ed789c32ca 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepositoryCustom.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedCustomizedUserRepositoryCustom.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedEntityManagerProducer.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedEntityManagerProducer.java index 5a2a045bb1..093b5afc16 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedEntityManagerProducer.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedEntityManagerProducer.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedFragment.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedFragment.java index f379d09c1b..4702152d9b 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedFragment.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedFragment.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedFragmentBean.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedFragmentBean.java index 6ff85427b4..54ce392031 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedFragmentBean.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedFragmentBean.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedPersonRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedPersonRepository.java index a9fc2fe9a3..487121fc7a 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedPersonRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/QualifiedPersonRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/RepositoryConsumer.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/RepositoryConsumer.java index df3c96474f..2833ea63b0 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/RepositoryConsumer.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/RepositoryConsumer.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -36,6 +36,9 @@ public void findAll() { public void save(Person person) { unqualifiedRepo.save(person); + } + + public void saveUserDb(Person person) { qualifiedRepo.save(person); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepository.java index 5e99a709a4..559370d8fe 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepositoryCustom.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepositoryCustom.java index 072332f0fb..66a910a882 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepositoryCustom.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepositoryCustom.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepositoryImpl.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepositoryImpl.java index 4e14a00ea4..ed7ef43cdd 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepositoryImpl.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/SamplePersonRepositoryImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/Transactional.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/Transactional.java index ddc35d77bc..d50e810b25 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/Transactional.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/Transactional.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/TransactionalInterceptor.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/TransactionalInterceptor.java index 1e9813df95..51d2bc3b29 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/TransactionalInterceptor.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/TransactionalInterceptor.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UnqualifiedEntityManagerProducer.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UnqualifiedEntityManagerProducer.java index d18edec881..43eded0a81 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UnqualifiedEntityManagerProducer.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UnqualifiedEntityManagerProducer.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UnqualifiedPersonRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UnqualifiedPersonRepository.java index 47677d70ac..fb7a4dd096 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UnqualifiedPersonRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UnqualifiedPersonRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UserDB.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UserDB.java index d89737beb0..93f14563c7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UserDB.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/cdi/UserDB.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. 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 7a5fb05c0d..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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -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 users = auditableUserRepository.findAll(); for (AuditableUser user : users) { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractRepositoryConfigTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractRepositoryConfigTests.java index 8d6baca3e3..64da44a6a0 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractRepositoryConfigTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractRepositoryConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AllowNestedRepositoriesRepositoryConfigTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AllowNestedRepositoriesRepositoryConfigTests.java index 6110e02b1c..edae851478 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AllowNestedRepositoriesRepositoryConfigTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AllowNestedRepositoriesRepositoryConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParserTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParserTests.java index 14edf2c213..b739493a23 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParserTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParserTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/CustomRepositoryFactoryConfigTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/CustomRepositoryFactoryConfigTests.java index 9dc0b3d0fc..02f2f2a1fe 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/CustomRepositoryFactoryConfigTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/CustomRepositoryFactoryConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/DefaultAuditingViaJavaConfigRepositoriesTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/DefaultAuditingViaJavaConfigRepositoriesTests.java index 69206bb28e..5e40224732 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/DefaultAuditingViaJavaConfigRepositoriesTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/DefaultAuditingViaJavaConfigRepositoriesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/ExplicitAuditingViaJavaConfigRepositoriesTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/ExplicitAuditingViaJavaConfigRepositoriesTests.java index cc3ff4e612..d0c5fd06f4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/ExplicitAuditingViaJavaConfigRepositoriesTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/ExplicitAuditingViaJavaConfigRepositoriesTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/InfrastructureConfig.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/InfrastructureConfig.java index 8542911649..4fdaad1022 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/InfrastructureConfig.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/InfrastructureConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/InspectionClassLoaderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/InspectionClassLoaderUnitTests.java index beb61ed5ad..0b3da373e5 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/InspectionClassLoaderUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/InspectionClassLoaderUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaAuditingRegistrarUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaAuditingRegistrarUnitTests.java index 623f3d5249..504ac40d24 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaAuditingRegistrarUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaAuditingRegistrarUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrarIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrarIntegrationTests.java index d6b550f0ea..8d4a11955e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrarIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrarIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrarUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrarUnitTests.java index 144079a085..3b68b30365 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrarUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoriesRegistrarUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -18,11 +18,16 @@ import static org.assertj.core.api.Assertions.assertThat; import java.util.Arrays; +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.MethodSource; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.context.annotation.AnnotationBeanNameGenerator; import org.springframework.core.env.StandardEnvironment; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.type.AnnotationMetadata; @@ -34,21 +39,21 @@ * @author Oliver Gierke * @author Jens Schauder * @author Erik Pellizzon + * @author Christoph Strobl */ class JpaRepositoriesRegistrarUnitTests { private BeanDefinitionRegistry registry; - private AnnotationMetadata metadata; @BeforeEach void setUp() { - - metadata = AnnotationMetadata.introspect(Config.class); registry = new DefaultListableBeanFactory(); } - @Test - void configuresRepositoriesCorrectly() { + + @ParameterizedTest // GH-499, GH-3440 + @MethodSource(value = { "args" }) + void configuresRepositoriesCorrectly(AnnotationMetadata metadata, String[] beanNames) { JpaRepositoriesRegistrar registrar = new JpaRepositoriesRegistrar(); registrar.setResourceLoader(new DefaultResourceLoader()); @@ -56,11 +61,32 @@ void configuresRepositoriesCorrectly() { registrar.registerBeanDefinitions(metadata, registry); Iterable names = Arrays.asList(registry.getBeanDefinitionNames()); - assertThat(names).contains("userRepository", "auditableUserRepository", "roleRepository"); + assertThat(names).contains(beanNames); + } + + static Stream args() { + return Stream.of( + Arguments.of(AnnotationMetadata.introspect(Config.class), + new String[] { "userRepository", "auditableUserRepository", "roleRepository" }), + Arguments.of(AnnotationMetadata.introspect(ConfigWithBeanNameGenerator.class), + new String[] { "userREPO", "auditableUserREPO", "roleREPO" })); } @EnableJpaRepositories(basePackageClasses = UserRepository.class) private class Config { } + + @EnableJpaRepositories(basePackageClasses = UserRepository.class, nameGenerator = MyBeanNameGenerator.class) + private class ConfigWithBeanNameGenerator { + + } + + static class MyBeanNameGenerator extends AnnotationBeanNameGenerator { + + @Override + public String generateBeanName(BeanDefinition definition, BeanDefinitionRegistry registry) { + return super.generateBeanName(definition, registry).replaceAll("Repository", "REPO"); + } + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigDefinitionParserTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigDefinitionParserTests.java index 09751277d3..2cbadc8275 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigDefinitionParserTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigDefinitionParserTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtensionUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtensionUnitTests.java index 54ca71d9f7..6412c70f88 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtensionUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtensionUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -30,8 +30,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; + import org.springframework.beans.factory.NoSuchBeanDefinitionException; -import org.springframework.beans.factory.support.AbstractBeanDefinition; import org.springframework.beans.factory.support.BeanDefinitionReaderUtils; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.beans.factory.support.RootBeanDefinition; @@ -149,20 +149,6 @@ void exposesJpaAotProcessor() { .isEqualTo(JpaRepositoryConfigExtension.JpaRepositoryRegistrationAotProcessor.class); } - @Test // GH-2730 - void shouldNotRegisterEntityManagerAsSynthetic() { - - DefaultListableBeanFactory factory = new DefaultListableBeanFactory(); - - RepositoryConfigurationExtension extension = new JpaRepositoryConfigExtension(); - extension.registerBeansForRoot(factory, configSource); - - AbstractBeanDefinition bd = (AbstractBeanDefinition) factory.getBeanDefinition("jpaSharedEM_" - + configSource.getAttribute("entityManagerFactoryRef").orElse("entityManagerFactory")); - - assertThat(bd.isSynthetic()).isEqualTo(false); - } - private void assertOnlyOnePersistenceAnnotationBeanPostProcessorRegistered(DefaultListableBeanFactory factory, String expectedBeanName) { 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 ef9011e437..25277bad6c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -16,38 +16,65 @@ package org.springframework.data.jpa.repository.config; import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; import jakarta.persistence.Entity; +import jakarta.persistence.Id; import java.lang.annotation.Annotation; +import java.net.URL; import java.util.Collections; +import java.util.List; import java.util.Set; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.ClearSystemProperty; +import org.junitpioneer.jupiter.SetSystemProperty; + +import org.springframework.aot.AotDetector; import org.springframework.aot.generate.ClassNameGenerator; import org.springframework.aot.generate.DefaultGenerationContext; import org.springframework.aot.generate.GenerationContext; import org.springframework.aot.generate.InMemoryGeneratedFiles; import org.springframework.aot.hint.predicate.RuntimeHintsPredicates; import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.context.support.AbstractApplicationContext; +import org.springframework.context.support.GenericApplicationContext; import org.springframework.core.annotation.MergedAnnotation; -import org.springframework.data.repository.config.AotRepositoryContext; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.data.aot.AotContext; +import org.springframework.data.jpa.repository.aot.JpaRepositoryContributor; +import org.springframework.data.jpa.repository.support.SimpleJpaRepository; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.config.AotRepositoryContextSupport; +import org.springframework.data.repository.config.AotRepositoryInformation; +import org.springframework.data.repository.config.RepositoryConfigurationSource; import org.springframework.data.repository.core.RepositoryInformation; +import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; import org.springframework.javapoet.ClassName; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; /** + * Unit tests for {@link JpaRepositoryConfigExtension.JpaRepositoryRegistrationAotProcessor}. + * * @author Christoph Strobl + * @author Hyunsang Han + * @author Mark Paluch */ class JpaRepositoryRegistrationAotProcessorUnitTests { @Test // GH-2628 + @Disabled("TODO: Superfluous contributeType in Commons") void aotProcessorMustNotRegisterDomainTypes() { - GenerationContext ctx = new DefaultGenerationContext(new ClassNameGenerator(ClassName.OBJECT), - new InMemoryGeneratedFiles()); + GenerationContext ctx = createGenerationContext(); + GenericApplicationContext context = new GenericApplicationContext(); new JpaRepositoryConfigExtension.JpaRepositoryRegistrationAotProcessor() - .contribute(new DummyAotRepositoryContext() { + .configureTypeContributions(new DummyAotRepositoryContext(context) { @Override public Set> getResolvedTypes() { return Collections.singleton(Person.class); @@ -60,11 +87,11 @@ public Set> getResolvedTypes() { @Test // GH-2628 void aotProcessorMustNotRegisterAnnotations() { - GenerationContext ctx = new DefaultGenerationContext(new ClassNameGenerator(ClassName.OBJECT), - new InMemoryGeneratedFiles()); + GenerationContext ctx = createGenerationContext(); + GenericApplicationContext context = new GenericApplicationContext(); new JpaRepositoryConfigExtension.JpaRepositoryRegistrationAotProcessor() - .contribute(new DummyAotRepositoryContext() { + .configureTypeContributions(new DummyAotRepositoryContext(context) { @Override public Set> getResolvedAnnotations() { @@ -77,18 +104,120 @@ public Set> getResolvedAnnotations() { assertThat(RuntimeHintsPredicates.reflection().onType(Entity.class)).rejects(ctx.getRuntimeHints()); } - static class Person {} + @Test // GH-3838 + void repositoryProcessorShouldConsiderPersistenceManagedTypes() { + + GenericApplicationContext context = new GenericApplicationContext(); + context.registerBean(PersistenceManagedTypes.class, () -> { + + return new PersistenceManagedTypes() { + @Override + public List getManagedClassNames() { + return List.of(Person.class.getName()); + } + + @Override + public List getManagedPackages() { + return List.of(); + } + + @Override + public @Nullable URL getPersistenceUnitRootUrl() { + return null; + } + }; + }); + + JpaRepositoryContributor contributor = new JpaRepositoryConfigExtension.JpaRepositoryRegistrationAotProcessor() + .contributeAotRepository(new DummyAotRepositoryContext(context)); + + assertThat(contributor.getMetamodel().managedType(Person.class)).isNotNull(); + } + + @Test // GH-3899 + @SetSystemProperty(key = AotDetector.AOT_ENABLED, value = "true") + void repositoryProcessorShouldEnableAotRepositoriesByDefaultWhenAotIsEnabled() { - static class DummyAotRepositoryContext implements AotRepositoryContext { + GenericApplicationContext context = new GenericApplicationContext(); + + JpaRepositoryContributor contributor = createContributorWithPersonTypes(context); + + assertThat(contributor).isNotNull(); + } + + @Test // GH-3899 + @ClearSystemProperty(key = AotContext.GENERATED_REPOSITORIES_ENABLED) + void shouldEnableAotRepositoriesByDefault() { + + GenericApplicationContext context = new GenericApplicationContext(); + + JpaRepositoryContributor contributor = createContributorWithPersonTypes(context); + + assertThat(contributor).isNotNull(); + } + + @Test // GH-3899 + @SetSystemProperty(key = AotContext.GENERATED_REPOSITORIES_ENABLED, value = "false") + void shouldDisableAotRepositoriesWhenGeneratedRepositoriesIsFalse() { + + GenericApplicationContext context = new GenericApplicationContext(); + + JpaRepositoryContributor contributor = createContributorWithPersonTypes(context); + + assertThat(contributor).isNull(); + } + + @Test // GH-3899 + @SetSystemProperty(key = "spring.aot.jpa.repositories.enabled", value = "false") + void shouldDisableAotRepositoriesWhenJpaGeneratedRepositoriesIsFalse() { + + GenericApplicationContext context = new GenericApplicationContext(); + + JpaRepositoryContributor contributor = createContributorWithPersonTypes(context); + + assertThat(contributor).isNull(); + } + + private GenerationContext createGenerationContext() { + return new DefaultGenerationContext(new ClassNameGenerator(ClassName.OBJECT), + new InMemoryGeneratedFiles()); + } + + private JpaRepositoryContributor createContributorWithPersonTypes(GenericApplicationContext context) { + + return new JpaRepositoryConfigExtension.JpaRepositoryRegistrationAotProcessor() + .contributeAotRepository(new DummyAotRepositoryContext(context) { + @Override + public Set> getResolvedTypes() { + return Collections.singleton(Person.class); + } + }); + } + + @Entity + static class Person { + @Id Long id; + } + + interface PersonRepository extends Repository {} + + static class DummyAotRepositoryContext extends AotRepositoryContextSupport { + + private final AbstractApplicationContext applicationContext; + + DummyAotRepositoryContext(AbstractApplicationContext applicationContext) { + super(AotContext.from(applicationContext, applicationContext.getEnvironment())); + this.applicationContext = applicationContext; + } @Override - public String getBeanName() { - return "jpaRepository"; + public String getModuleName() { + return "JPA"; } @Override - public Set getBasePackages() { - return Collections.singleton(this.getClass().getPackageName()); + public RepositoryConfigurationSource getConfigurationSource() { + return mock(RepositoryConfigurationSource.class); } @Override @@ -98,32 +227,31 @@ public Set> getIdentifyingAnnotations() { @Override public RepositoryInformation getRepositoryInformation() { - return null; + return new AotRepositoryInformation(AbstractRepositoryMetadata.getMetadata(PersonRepository.class), + SimpleJpaRepository.class, List.of()); } @Override public Set> getResolvedAnnotations() { - return null; + return Set.of(); } @Override public Set> getResolvedTypes() { - return null; + return Set.of(); } @Override public ConfigurableListableBeanFactory getBeanFactory() { - return null; + return applicationContext != null ? applicationContext.getBeanFactory() : null; } @Override - public TypeIntrospector introspectType(String typeName) { - return null; + public Environment getEnvironment() { + return applicationContext == null ? new StandardEnvironment() : applicationContext.getEnvironment(); } - @Override - public IntrospectedBeanDefinition introspectBeanDefinition(String beanName) { - return null; - } + } + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/NestedRepositoriesJavaConfigTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/NestedRepositoriesJavaConfigTests.java index 2cf100c402..7384a64cb6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/NestedRepositoriesJavaConfigTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/NestedRepositoriesJavaConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/QueryLookupStrategyTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/QueryLookupStrategyTests.java index 7ea61726f6..22cb434931 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/QueryLookupStrategyTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/QueryLookupStrategyTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoriesJavaConfigTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoriesJavaConfigTests.java index 688e5471a8..bb40ccfc01 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoriesJavaConfigTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoriesJavaConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoryAutoConfigTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoryAutoConfigTests.java index a6eada978a..f08f7bb2ee 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoryAutoConfigTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoryAutoConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoryConfigTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoryConfigTests.java index 10977363f9..722e137690 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoryConfigTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/RepositoryConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/TypeFilterConfigTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/TypeFilterConfigTests.java index b8e35ce52b..56b449f9b6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/TypeFilterConfigTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/TypeFilterConfigTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepository.java index e9c53f287a..00fe7c6aa1 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepositoryFactory.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepositoryFactory.java index ab6b76002b..d898522b32 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepositoryFactory.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepositoryFactory.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepositoryFactoryBean.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepositoryFactoryBean.java index 09a702b6f0..b2c13f48dc 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepositoryFactoryBean.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericJpaRepositoryFactoryBean.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericRepository.java index b39c31c296..b5d8b10b8e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/CustomGenericRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/UserCustomExtendedRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/UserCustomExtendedRepository.java index dd537e7155..1606e832f8 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/UserCustomExtendedRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/custom/UserCustomExtendedRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/generics/EclipseLinkGenericsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/generics/EclipseLinkGenericsIntegrationTests.java index e7725e53e6..9c3019106f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/generics/EclipseLinkGenericsIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/generics/EclipseLinkGenericsIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/generics/GenericsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/generics/GenericsIntegrationTests.java index 364bcbfa7f..ce40a725bc 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/generics/GenericsIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/generics/GenericsIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/MySqlStoredProcedureIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/MySqlStoredProcedureIntegrationTests.java index 6755dbb88c..64d52bc1d4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/MySqlStoredProcedureIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/MySqlStoredProcedureIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. @@ -19,20 +19,19 @@ import static org.assertj.core.api.Assertions.*; import jakarta.persistence.Entity; -import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.NamedStoredProcedureQuery; +import java.net.URL; import java.util.List; import java.util.Objects; -import java.util.Properties; -import javax.sql.DataSource; - -import org.hibernate.dialect.MySQL8Dialect; +import org.hibernate.dialect.MySQLDialect; +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.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan.Filter; @@ -41,20 +40,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.query.Procedure; -import org.springframework.jdbc.datasource.init.DataSourceInitializer; -import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; -import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean; -import org.springframework.orm.jpa.JpaTransactionManager; -import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; -import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.Transactional; -import org.testcontainers.containers.MySQLContainer; -import com.mysql.cj.jdbc.MysqlDataSource; +import org.testcontainers.containers.MySQLContainer; /** * Testcase to verify {@link org.springframework.jdbc.object.StoredProcedure}s work with MySQL. @@ -149,8 +142,7 @@ void testEntityListFromNamedProcedure() { resultClasses = Employee.class) public static class Employee { - @Id - @GeneratedValue // + @Id @GeneratedValue // private Integer id; private String name; @@ -181,10 +173,12 @@ public void setName(String name) { @Override public boolean equals(Object o) { - if (this == o) + if (this == o) { return true; - if (o == null || getClass() != o.getClass()) + } + if (o == null || getClass() != o.getClass()) { return false; + } Employee employee = (Employee) o; return Objects.equals(id, employee.id) && Objects.equals(name, employee.name); } @@ -194,6 +188,7 @@ public int hashCode() { return Objects.hash(id, name); } + @Override public String toString() { return "MySqlStoredProcedureIntegrationTests.Employee(id=" + this.getId() + ", name=" + this.getName() + ")"; } @@ -232,7 +227,32 @@ public interface EmployeeRepositoryWithNoCursor extends JpaRepository getManagedClassNames() { + return List.of(Employee.class.getName()); + } + + @Override + public List getManagedPackages() { + return List.of(); + } + + @Override + public @Nullable URL getPersistenceUnitRootUrl() { + return null; + } + }; + + } @SuppressWarnings("resource") @Bean(initMethod = "start", destroyMethod = "stop") @@ -243,51 +263,5 @@ public MySQLContainer container() { .withPassword("test") // .withConfigurationOverride(""); } - - @Bean - public DataSource dataSource(MySQLContainer container) { - - MysqlDataSource dataSource = new MysqlDataSource(); - dataSource.setUrl(container.getJdbcUrl()); - dataSource.setUser(container.getUsername()); - dataSource.setPassword(container.getPassword()); - return dataSource; - } - - @Bean - public AbstractEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { - - LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); - factoryBean.setDataSource(dataSource); - factoryBean.setPersistenceUnitRootLocation("simple-persistence"); - factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); - factoryBean.setPackagesToScan(this.getClass().getPackage().getName()); - - Properties properties = new Properties(); - properties.setProperty("hibernate.hbm2ddl.auto", "create"); - properties.setProperty("hibernate.dialect", MySQL8Dialect.class.getCanonicalName()); - factoryBean.setJpaProperties(properties); - - return factoryBean; - } - - @Bean - PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { - return new JpaTransactionManager(entityManagerFactory); - } - - @Bean - DataSourceInitializer initializer(DataSource dataSource) { - - DataSourceInitializer initializer = new DataSourceInitializer(); - initializer.setDataSource(dataSource); - - ClassPathResource script = new ClassPathResource("scripts/mysql-stored-procedures.sql"); - ResourceDatabasePopulator populator = new ResourceDatabasePopulator(script); - populator.setSeparator(";;"); - initializer.setDatabasePopulator(populator); - - return initializer; - } } } 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 1be3894a9e..77f46a5518 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 @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. @@ -16,10 +16,9 @@ package org.springframework.data.jpa.repository.procedures; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import jakarta.persistence.Entity; -import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.NamedStoredProcedureQuery; @@ -27,17 +26,16 @@ import jakarta.persistence.StoredProcedureParameter; import java.math.BigDecimal; +import java.net.URL; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Properties; - -import javax.sql.DataSource; import org.hibernate.dialect.PostgreSQLDialect; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.postgresql.ds.PGSimpleDataSource; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan.Filter; @@ -46,18 +44,14 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.query.Procedure; -import org.springframework.data.jpa.util.DisabledOnHibernate62; -import org.springframework.jdbc.datasource.init.DataSourceInitializer; -import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; -import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean; -import org.springframework.orm.jpa.JpaTransactionManager; -import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; -import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport; +import org.springframework.data.jpa.util.DisabledOnHibernate; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.Transactional; + import org.testcontainers.containers.PostgreSQLContainer; /** @@ -67,6 +61,7 @@ * @author Greg Turnquist * @author Yanming Zhou * @author Thorben Janssen + * @author Mark Paluch */ @Transactional @ExtendWith(SpringExtension.class) @@ -115,7 +110,8 @@ void testNamedOutputParameter() { new Employee(4, "Gabriel")); } - @DisabledOnHibernate62 + @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() { @@ -150,7 +146,7 @@ void testEntityListFromNamedProcedure() { new Employee(4, "Gabriel")); } - @Test // 3460 + @Test // GH-3460 void testPositionalInOutParameter() { Map results = repository.positionalInOut(1, 2); @@ -159,12 +155,48 @@ void testPositionalInOutParameter() { assertThat(results.get("3")).isEqualTo(3); } + @Test // GH-3460 + void supportsMultipleOutParameters() { + + Map results = repository.multiple_out(5); + + assertThat(results).containsEntry("result1", 5).containsEntry("result2", 10); + assertThat(results).containsKey("some_cursor"); + } + + @Test // GH-3081 + @DisabledOnHibernate(value = "6.2", + disabledReason = "Hibernate 6.2 does not support stored procedures with array types") + void supportsArrayTypes() { + + String result = repository.accept_array(new String[] { "one", "two" }); + + assertThat(result).isEqualTo("[1:2]"); + } + @Entity @NamedStoredProcedureQuery( // name = "get_employees_postgres", // procedureName = "get_employees", // parameters = { @StoredProcedureParameter(mode = ParameterMode.REF_CURSOR, type = void.class) }, // resultClasses = Employee.class) + + @NamedStoredProcedureQuery( // + name = "Employee.noResultSet", // + procedureName = "get_employees_count", // + parameters = { @StoredProcedureParameter(mode = ParameterMode.OUT, name = "results", type = Integer.class) }) + @NamedStoredProcedureQuery( // + name = "Employee.multiple_out", // + procedureName = "multiple_out", // + parameters = { @StoredProcedureParameter(mode = ParameterMode.IN, name = "someNumber", type = Integer.class), + @StoredProcedureParameter(mode = ParameterMode.REF_CURSOR, name = "some_cursor", type = void.class), + @StoredProcedureParameter(mode = ParameterMode.OUT, name = "result1", type = Integer.class), + @StoredProcedureParameter(mode = ParameterMode.OUT, name = "result2", type = Integer.class) }) + @NamedStoredProcedureQuery( // + name = "Employee.accept_array", // + procedureName = "accept_array", // + parameters = { @StoredProcedureParameter(mode = ParameterMode.IN, name = "some_chars", type = String[].class), + @StoredProcedureParameter(mode = ParameterMode.OUT, name = "dims", type = String.class) }) @NamedStoredProcedureQuery( // name = "positional_inout", // procedureName = "positional_inout_parameter_issue3460", // @@ -248,6 +280,12 @@ public interface EmployeeRepositoryWithRefCursor extends JpaRepository multiple_out(int someNumber); + @Procedure(name = "get_employees_postgres", refCursor = true) List entityListFromNamedProcedure(); @@ -258,60 +296,39 @@ public interface EmployeeRepositoryWithRefCursor extends JpaRepository container() { - - return new PostgreSQLContainer<>("postgres:15.3") // - .withUsername("postgres"); - } - - @Bean - public DataSource dataSource(PostgreSQLContainer container) { + static class Config extends TestcontainerConfigSupport { - PGSimpleDataSource dataSource = new PGSimpleDataSource(); - dataSource.setUrl(container.getJdbcUrl()); - dataSource.setUser(container.getUsername()); - dataSource.setPassword(container.getPassword()); - return dataSource; + public Config() { + super(PostgreSQLDialect.class, new ClassPathResource("scripts/postgres-stored-procedures.sql")); } - @Bean - public AbstractEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { - - LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); - factoryBean.setDataSource(dataSource); - factoryBean.setPersistenceUnitRootLocation("simple-persistence"); - factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); - factoryBean.setPackagesToScan(this.getClass().getPackage().getName()); - - Properties properties = new Properties(); - properties.setProperty("hibernate.hbm2ddl.auto", "create"); - properties.setProperty("hibernate.dialect", PostgreSQLDialect.class.getCanonicalName()); - factoryBean.setJpaProperties(properties); - - return factoryBean; - } + @Override + protected PersistenceManagedTypes getManagedTypes() { + return new PersistenceManagedTypes() { + @Override + public List getManagedClassNames() { + return List.of(Employee.class.getName()); + } + + @Override + public List getManagedPackages() { + return List.of(); + } + + @Override + public @Nullable URL getPersistenceUnitRootUrl() { + return null; + } + }; - @Bean - PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { - return new JpaTransactionManager(entityManagerFactory); } - @Bean - DataSourceInitializer initializer(DataSource dataSource) { - - DataSourceInitializer initializer = new DataSourceInitializer(); - initializer.setDataSource(dataSource); - - ClassPathResource script = new ClassPathResource("scripts/postgres-stored-procedures.sql"); - ResourceDatabasePopulator populator = new ResourceDatabasePopulator(script); - populator.setSeparator(";;"); - initializer.setDatabasePopulator(populator); + @SuppressWarnings("resource") + @Bean(initMethod = "start", destroyMethod = "stop") + public PostgreSQLContainer container() { - return initializer; + return new PostgreSQLContainer<>("postgres:15.3") // + .withUsername("postgres"); } } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureNullHandlingIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureNullHandlingIntegrationTests.java index bf52ffc665..125a2acbc7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureNullHandlingIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureNullHandlingIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. @@ -16,21 +16,20 @@ package org.springframework.data.jpa.repository.procedures; import jakarta.persistence.Entity; -import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import java.net.URL; import java.util.Date; -import java.util.Properties; +import java.util.List; import java.util.UUID; -import javax.sql.DataSource; - import org.hibernate.dialect.PostgreSQLDialect; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.postgresql.ds.PGSimpleDataSource; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -40,18 +39,14 @@ import org.springframework.data.jpa.repository.Temporal; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.jpa.repository.query.Procedure; -import org.springframework.data.jpa.util.DisabledOnHibernate61; -import org.springframework.jdbc.datasource.init.DataSourceInitializer; -import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; -import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean; -import org.springframework.orm.jpa.JpaTransactionManager; -import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; -import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.data.jpa.repository.support.TestcontainerConfigSupport; +import org.springframework.data.jpa.util.DisabledOnHibernate; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.transaction.annotation.Transactional; + import org.testcontainers.containers.PostgreSQLContainer; /** @@ -59,7 +54,7 @@ * * @author Greg Turnquist */ -@DisabledOnHibernate61 // GH-2903 +@DisabledOnHibernate("6.1") // GH-2903 @Transactional @ExtendWith(SpringExtension.class) @ContextConfiguration(classes = PostgresStoredProcedureNullHandlingIntegrationTests.Config.class) @@ -132,69 +127,45 @@ public interface TestModelRepository extends JpaRepository { void countUuid(UUID this_uuid); @Procedure("countByLocalDate") - void countLocalDate(@Temporal Date localDate); + void countLocalDate(@Temporal Date this_local_date); } @EnableJpaRepositories(considerNestedRepositories = true, includeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = TestModelRepository.class)) @EnableTransactionManagement - static class Config { - - @Bean(initMethod = "start", destroyMethod = "stop") - public PostgreSQLContainer container() { - - return new PostgreSQLContainer<>("postgres:15.3") // - .withUsername("postgres"); - } - - @Bean - public DataSource dataSource(PostgreSQLContainer container) { + static class Config extends TestcontainerConfigSupport { - PGSimpleDataSource dataSource = new PGSimpleDataSource(); - dataSource.setUrl(container.getJdbcUrl()); - dataSource.setUser(container.getUsername()); - dataSource.setPassword(container.getPassword()); - - return dataSource; + public Config() { + super(PostgreSQLDialect.class, new ClassPathResource("scripts/postgres-nullable-stored-procedures.sql")); } - @Bean - public AbstractEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { - - LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); - factoryBean.setDataSource(dataSource); - factoryBean.setPersistenceUnitRootLocation("simple-persistence"); - factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); - factoryBean.setPackagesToScan(this.getClass().getPackage().getName()); - - Properties properties = new Properties(); - properties.setProperty("hibernate.hbm2ddl.auto", "create"); - properties.setProperty("hibernate.dialect", PostgreSQLDialect.class.getCanonicalName()); - properties.setProperty("hibernate.proc.param_null_passing", "true"); - properties.setProperty("hibernate.globally_quoted_identifiers", "true"); - properties.setProperty("hibernate.globally_quoted_identifiers_skip_column_definitions", "true"); - factoryBean.setJpaProperties(properties); + @Override + protected PersistenceManagedTypes getManagedTypes() { + return new PersistenceManagedTypes() { + @Override + public List getManagedClassNames() { + return List.of(TestModel.class.getName()); + } + + @Override + public List getManagedPackages() { + return List.of(); + } + + @Override + public @Nullable URL getPersistenceUnitRootUrl() { + return null; + } + }; - return factoryBean; } - @Bean - PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { - return new JpaTransactionManager(entityManagerFactory); - } - - @Bean - DataSourceInitializer initializer(DataSource dataSource) { - - DataSourceInitializer initializer = new DataSourceInitializer(); - initializer.setDataSource(dataSource); - - ClassPathResource script = new ClassPathResource("scripts/postgres-nullable-stored-procedures.sql"); - ResourceDatabasePopulator populator = new ResourceDatabasePopulator(script); - populator.setSeparator(";;"); - initializer.setDatabasePopulator(populator); + @SuppressWarnings("resource") + @Bean(initMethod = "start", destroyMethod = "stop") + public PostgreSQLContainer container() { - return initializer; + return new PostgreSQLContainer<>("postgres:15.3") // + .withUsername("postgres"); } } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/projections/ProjectionJoinIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/projections/ProjectionJoinIntegrationTests.java index 1c952765e6..d0bdce94bd 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/projections/ProjectionJoinIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/projections/ProjectionJoinIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/projections/ProjectionsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/projections/ProjectionsIntegrationTests.java index f744dd540d..9abc3716e0 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/projections/ProjectionsIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/projections/ProjectionsIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractDtoQueryTransformerUnitTests.java new file mode 100644 index 0000000000..fcede5da49 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractDtoQueryTransformerUnitTests.java @@ -0,0 +1,167 @@ +/* + * 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 java.lang.reflect.Method; + +import org.antlr.v4.runtime.tree.ParseTreeVisitor; +import org.junit.jupiter.api.Test; + +import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; +import org.springframework.data.repository.query.QueryMethod; + +/** + * Support class for unit tests for {@link DtoProjectionTransformerDelegate}. + * + * @author Mark Paluch + */ +abstract class AbstractDtoQueryTransformerUnitTests

      > { + + JpaQueryMethod method = getMethod("dtoProjection"); + + @Test // GH-3076 + void shouldRewritePrimarySelectionToConstructorExpressionWithProperties() { + + P parser = parse("SELECT p from Person p"); + + QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.AbstractDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar) from Person p"); + } + + @Test // GH-3076, GH-3895 + void shouldRewriteSelectionToConstructorExpression() { + + P parser = parse("SELECT p.name from Person p"); + + QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.AbstractDtoQueryTransformerUnitTests$MyRecord(p.name) from Person p"); + } + + @Test // GH-3076 + void shouldRewriteQueriesWithSubselect() { + + P parser = parse("select u from User u left outer join u.roles r where r in (select r from Role r)"); + + QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "select new org.springframework.data.jpa.repository.query.AbstractDtoQueryTransformerUnitTests$MyRecord(u.foo, u.bar) from User u left outer join u.roles r where r in (select r from Role r)"); + } + + @Test // GH-3076 + void shouldNotRewriteQueriesWithoutProperties() { + + JpaQueryMethod method = getMethod("noProjection"); + P parser = parse("select u from User u"); + + QueryTokenStream visit = getTransformer(parser, method).visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("select u from User u"); + } + + @Test // GH-3076 + void shouldNotTranslateConstructorExpressionQuery() { + + P parser = parse("SELECT NEW com.foo(p) from Person p"); + + QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW com.foo(p) from Person p"); + } + + @Test // GH-3076 + void shouldTranslatePropertySelectionToDto() { + + P parser = parse("SELECT p.foo, p.bar, sum(p.age) from Person p"); + + QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.AbstractDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar, sum(p.age)) from Person p"); + } + + @Test // GH-3895 + void shouldStripAliasesFromDtoProjection() { + + P parser = parse("SELECT sum(p.age) As age, p.foo as foo, p.bar AS bar from Person p"); + + QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.AbstractDtoQueryTransformerUnitTests$MyRecord(sum(p.age), p.foo, p.bar) from Person p"); + } + + @Test // GH-3895 + void shouldStripAliasesFromDtoProjectionWithSubquery() { + + P parser = parse( + "SELECT p.foo as foo, p.bar AS bar, cast(p.age as INTEGER) As age, (SELECT b.foo FROM Bar AS b) from Person p"); + + QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); + + assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( + "SELECT new org.springframework.data.jpa.repository.query.AbstractDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar, cast(p.age as INTEGER), (SELECT b.foo FROM Bar AS b)) from Person p"); + } + + private JpaQueryMethod getMethod(String name, Class... parameterTypes) { + + try { + Method method = MyRepo.class.getMethod(name, parameterTypes); + PersistenceProvider persistenceProvider = PersistenceProvider.HIBERNATE; + + return new JpaQueryMethod(method, new DefaultRepositoryMetadata(MyRepo.class), + new SpelAwareProxyProjectionFactory(), persistenceProvider); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + abstract P parse(String query); + + private ParseTreeVisitor getTransformer(P parser) { + return getTransformer(parser, method); + } + + abstract ParseTreeVisitor getTransformer(P parser, QueryMethod method); + + interface MyRepo extends Repository { + + MyRecord dtoProjection(); + + EmptyClass noProjection(); + } + + record Person(String id) { + + } + + record MyRecord(String foo, String bar) { + + } + + static class EmptyClass { + + } +} 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 1f7db3cc42..3d9a616120 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -221,7 +221,7 @@ class DummyJpaQuery extends AbstractJpaQuery { } @Override - protected JpaQueryExecution getExecution() { + protected JpaQueryExecution getExecution(JpaParametersParameterAccessor accessor) { return execution; } @@ -230,6 +230,11 @@ protected Query doCreateQuery(JpaParametersParameterAccessor accessor) { return query; } + @Override + public boolean hasDeclaredCountQuery() { + return true; + } + @Override protected TypedQuery 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 6e6c605114..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 @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. @@ -26,6 +26,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Sort; @@ -33,12 +34,10 @@ 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; -import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; -import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -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, - QueryMethodEvaluationContextProvider.DEFAULT, new SpelExpressionParser()); + 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 5a33a4bf59..b254c41cef 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 @@ -1,5 +1,5 @@ /* - * Copyright 2024 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. @@ -18,6 +18,7 @@ import static org.mockito.Mockito.*; import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.metamodel.Metamodel; import java.lang.reflect.Method; @@ -27,23 +28,22 @@ 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; + import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.data.domain.Pageable; 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; import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; import org.springframework.data.repository.query.ParametersSource; -import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; import org.springframework.data.repository.query.ReturnedType; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.lang.Nullable; +import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.ReflectionUtils; @@ -52,9 +52,14 @@ * Unit tests for {@link AbstractStringBasedJpaQuery}. * * @author Christoph Strobl + * @author Mark Paluch + * @author Ariel Morelli Andres */ class AbstractStringBasedJpaQueryUnitTests { + private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(), + QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT); + @Test // GH-3310 void shouldNotAttemptToAppendSortIfNoSortArgumentPresent() { @@ -64,13 +69,14 @@ void shouldNotAttemptToAppendSortIfNoSortArgumentPresent() { stringQuery.neverCalled("applySorting"); } - @Test // GH-3310 - void shouldNotAttemptToAppendSortIfSortIndicatesUnsorted() { + @Test // GH-3310, GH-3076 + void shouldRunQueryRewriterOnce() { InvocationCapturingStringQueryStub stringQuery = forMethod(TestRepo.class, "find", Sort.class); stringQuery.createQueryWithArguments(Sort.unsorted()); + stringQuery.createQueryWithArguments(Sort.unsorted()); - stringQuery.neverCalled("applySorting"); + stringQuery.called("applySorting").times(1); } @Test // GH-3310 @@ -117,8 +123,7 @@ static InvocationCapturingStringQueryStub forMethod(Class repository, String Query query = AnnotatedElementUtils.getMergedAnnotation(respositoryMethod, Query.class); return new InvocationCapturingStringQueryStub(respositoryMethod, queryMethod, query.value(), query.countQuery(), - new SpelExpressionParser()); - + CONFIG); } static class InvocationCapturingStringQueryStub extends AbstractStringBasedJpaQuery { @@ -127,28 +132,29 @@ static class InvocationCapturingStringQueryStub extends AbstractStringBasedJpaQu private final MultiValueMap capturedArguments = new LinkedMultiValueMap<>(3); InvocationCapturingStringQueryStub(Method targetMethod, JpaQueryMethod queryMethod, String queryString, - @Nullable String countQueryString, SpelExpressionParser parser) { + @Nullable String countQueryString, JpaQueryConfiguration queryConfiguration) { super(queryMethod, new Supplier() { @Override public EntityManager get() { EntityManager em = Mockito.mock(EntityManager.class); + EntityManagerFactory emf = Mockito.mock(EntityManagerFactory.class); Metamodel meta = mock(Metamodel.class); when(em.getMetamodel()).thenReturn(meta); when(em.getDelegate()).thenReturn(new Object()); // some generic jpa + when(em.getEntityManagerFactory()).thenReturn(emf); return em; } - }.get(), queryString, countQueryString, Mockito.mock(QueryRewriter.class), - Mockito.mock(QueryMethodEvaluationContextProvider.class), parser); + }.get(), queryString, countQueryString, queryConfiguration); this.targetMethod = targetMethod; } @Override - protected String applySorting(CachableQuery query) { + protected QueryProvider applySorting(CachableQuery query) { captureInvocation("applySorting", query); @@ -156,12 +162,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/BadJpqlGrammarExceptionUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarExceptionUnitTests.java new file mode 100644 index 0000000000..16766673fc --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarExceptionUnitTests.java @@ -0,0 +1,59 @@ +/* + * 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.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link BadJpqlGrammarException}. + * + * @author Mark Paluch + */ +class BadJpqlGrammarExceptionUnitTests { + + @Test // GH-3757 + void shouldContainOriginalText() { + + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> JpaQueryEnhancer.HqlQueryParser + .parseQuery("SELECT e FROM Employee e WHERE FOO(x).bar RESPECTING NULLS")) + .withMessageContaining("no viable alternative") + .withMessageContaining("SELECT e FROM Employee e WHERE FOO(x).bar *RESPECTING NULLS") + .withMessageContaining("Bad HQL grammar [SELECT e FROM Employee e WHERE FOO(x).bar RESPECTING NULLS]"); + } + + @Test // GH-3757 + void shouldReportExtraneousInput() { + + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> JpaQueryEnhancer.HqlQueryParser.parseQuery("select * from User group by name")) + .withMessageContaining("extraneous input '*'") + .withMessageContaining("Bad HQL grammar [select * from User group by name]"); + } + + @Test // GH-3757 + void shouldReportMismatchedInput() { + + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> JpaQueryEnhancer.HqlQueryParser.parseQuery("SELECT AVG(m.price) AS m.avg FROM Magazine m")) + .withMessageContaining("mismatched input '.'").withMessageContaining("expecting one of the following tokens:") + .withMessageContaining("EXCEPT") + .withMessageContaining("Bad HQL grammar [SELECT AVG(m.price) AS m.avg FROM Magazine m]"); + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CollectionUtilsUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CollectionUtilsUnitTests.java index dd488b1ac5..9ab7aa87da 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CollectionUtilsUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CollectionUtilsUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CustomNonBindableJpaParametersIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CustomNonBindableJpaParametersIntegrationTests.java index 724f0e1bf7..bc850052d6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CustomNonBindableJpaParametersIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/CustomNonBindableJpaParametersIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2024 the original author or authors. + * Copyright 2019-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. @@ -92,19 +92,6 @@ private static class NonBindableAwareJpaParameters extends JpaParameters { } - private static class NonBindableAwareJpaQueryMethod extends JpaQueryMethod { - - NonBindableAwareJpaQueryMethod(Method method, RepositoryMetadata metadata, ProjectionFactory factory, - QueryExtractor extractor) { - super(method, metadata, factory, extractor); - } - - @Override - protected JpaParameters createParameters(ParametersSource source) { - return new NonBindableAwareJpaParameters(source); - } - } - private static class NonBindableAwareJpaQueryMethodFactory implements JpaQueryMethodFactory { private final QueryExtractor extractor; @@ -115,7 +102,7 @@ private NonBindableAwareJpaQueryMethodFactory(QueryExtractor extractor) { @Override public JpaQueryMethod build(Method method, RepositoryMetadata metadata, ProjectionFactory factory) { - return new NonBindableAwareJpaQueryMethod(method, metadata, factory, extractor); + return new JpaQueryMethod(method, metadata, factory, extractor, NonBindableAwareJpaParameters::new); } } 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 57% 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 556f75ebba..e860df4f92 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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -31,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 @@ -41,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); @@ -65,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()) @@ -89,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(); @@ -115,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"); @@ -132,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); @@ -160,10 +162,70 @@ void rewritesNamedLikeToUniqueParametersIfNecessary() { assertThat(((MethodInvocationArgument) parameterBinding.getOrigin()).identifier().getName()).isEqualTo("firstname"); } + @Test // GH-3784 + void rewritesNamedLikeToUniqueParametersRetainingCountQuery() { + + 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); + + assertThat(query.getQueryString()) // + .isEqualTo( + "select count(u) from User u where u.firstname like :firstname or u.firstname like :firstname_1 or u.firstname = :firstname_2"); + + List bindings = query.getParameterBindings(); + assertThat(bindings).hasSize(3); + + LikeParameterBinding binding = (LikeParameterBinding) bindings.get(0); + assertThat(binding).isNotNull(); + assertThat(binding.getOrigin()).isEqualTo(ParameterOrigin.ofParameter("firstname")); + assertThat(binding.getName()).isEqualTo("firstname"); + assertThat(binding.getType()).isEqualTo(Type.ENDING_WITH); + + binding = (LikeParameterBinding) bindings.get(1); + assertThat(binding).isNotNull(); + assertThat(binding.getOrigin()).isEqualTo(ParameterOrigin.ofParameter("firstname")); + assertThat(binding.getName()).isEqualTo("firstname_1"); + assertThat(binding.getType()).isEqualTo(Type.STARTING_WITH); + + ParameterBinding parameterBinding = bindings.get(2); + assertThat(parameterBinding).isNotNull(); + assertThat(parameterBinding.getOrigin()).isEqualTo(ParameterOrigin.ofParameter("firstname")); + assertThat(parameterBinding.getName()).isEqualTo("firstname_2"); + assertThat(((MethodInvocationArgument) parameterBinding.getOrigin()).identifier().getName()).isEqualTo("firstname"); + } + + @Test // GH-3784 + void rewritesExpressionsLikeToUniqueParametersRetainingCountQuery() { + + ParametrizedQuery query = new TestEntityQuery( + "select u from User u where u.firstname like %:#{firstname} or u.firstname like :#{firstname}%", false) + .deriveCountQuery(null); + + assertThat(query.getQueryString()) // + .isEqualTo( + "select count(u) from User u where u.firstname like :__$synthetic$__1 or u.firstname like :__$synthetic$__2"); + + List bindings = query.getParameterBindings(); + assertThat(bindings).hasSize(2); + + LikeParameterBinding binding = (LikeParameterBinding) bindings.get(0); + assertThat(binding).isNotNull(); + assertThat(binding.getOrigin().isExpression()).isTrue(); + assertThat(binding.getName()).isEqualTo("__$synthetic$__1"); + assertThat(binding.getType()).isEqualTo(Type.ENDING_WITH); + + binding = (LikeParameterBinding) bindings.get(1); + assertThat(binding).isNotNull(); + assertThat(binding.getOrigin().isExpression()).isTrue(); + assertThat(binding.getName()).isEqualTo("__$synthetic$__2"); + assertThat(binding.getType()).isEqualTo(Type.STARTING_WITH); + } + @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(); @@ -174,10 +236,25 @@ void rewritesPositionalLikeToUniqueParametersIfNecessary() { assertThat(bindings).hasSize(3); } + @Test // GH-3907 + void rewritesPositionalLikeToUniqueParametersIfNecessaryUsingPostgresJsonbOperator() { + + DefaultEntityQuery query = new TestEntityQuery( + "select '[\"x\", \"c\"]'::jsonb ?| '[\"x\", \"c\"]'::jsonb from User u where u.firstname like %?1 or u.firstname like ?1% or u.firstname = ?1", + true); + + assertThat(query.hasParameterBindings()).isTrue(); + assertThat(query.getQueryString()).isEqualTo( + "select '[\"x\", \"c\"]'::jsonb ?| '[\"x\", \"c\"]'::jsonb from User u where u.firstname like ?1 or u.firstname like ?2 or u.firstname = ?3"); + + List bindings = query.getParameterBindings(); + assertThat(bindings).hasSize(3); + } + @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); @@ -185,7 +262,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()) @@ -195,7 +273,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); @@ -203,16 +281,16 @@ 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"); + assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like ?1 or u.firstname = ?2"); } @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); @@ -231,11 +309,37 @@ void shouldRewritePositionalBindingsWithParameterReuse() { .containsOnly(1, 2); } + @Test // GH-3758 + void createsDistinctBindingsForIndexedSpel() { + + 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) + .containsOnly(1, 2); + assertThat(query.getParameterBindings()).extracting(ParameterBinding::getOrigin) + .extracting(ParameterOrigin::isExpression) // + .containsOnly(true, true); + } + + @Test // GH-3758 + void createsDistinctBindingsForNamedSpel() { + + 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) + .extracting(ParameterOrigin::isExpression) // + .containsOnly(true, true); + } + @Test // DATAJPA-461 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); @@ -250,7 +354,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); @@ -263,11 +367,53 @@ void detectsMultipleNamedInParameterBindings() { assertNamedBinding(ParameterBinding.class, "bar", bindings.get(2)); } + @Test // GH-3784 + void deriveCountQueryWithNamedInRetainsOrigin() { + + String queryString = "select u from User u where (:logins) IS NULL OR LOWER(u.login) IN (:logins)"; + 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)"); + + List bindings = query.getParameterBindings(); + assertThat(bindings).hasSize(2); + + assertNamedBinding(ParameterBinding.class, "logins", bindings.get(0)); + assertThat((MethodInvocationArgument) bindings.get(0).getOrigin()).extracting(MethodInvocationArgument::identifier) + .extracting(BindingIdentifier::getName).isEqualTo("logins"); + + assertNamedBinding(InParameterBinding.class, "logins_1", bindings.get(1)); + assertThat((MethodInvocationArgument) bindings.get(1).getOrigin()).extracting(MethodInvocationArgument::identifier) + .extracting(BindingIdentifier::getName).isEqualTo("logins"); + } + + @Test // GH-3784 + void deriveCountQueryWithPositionalInRetainsOrigin() { + + String queryString = "select u from User u where (?1) IS NULL OR LOWER(u.login) IN (?1)"; + 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)"); + + List bindings = query.getParameterBindings(); + assertThat(bindings).hasSize(2); + + assertPositionalBinding(ParameterBinding.class, 1, bindings.get(0)); + assertThat((MethodInvocationArgument) bindings.get(0).getOrigin()).extracting(MethodInvocationArgument::identifier) + .extracting(BindingIdentifier::getPosition).isEqualTo(1); + + assertPositionalBinding(InParameterBinding.class, 2, bindings.get(1)); + assertThat((MethodInvocationArgument) bindings.get(1).getOrigin()).extracting(MethodInvocationArgument::identifier) + .extracting(BindingIdentifier::getPosition).isEqualTo(1); + } + @Test // DATAJPA-461 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); @@ -281,7 +427,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(); @@ -294,7 +440,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); @@ -309,11 +455,79 @@ void allowsReuseOfParameterWithInAndRegularBinding() { assertNamedBinding(InParameterBinding.class, "foo_1", bindings.get(1)); } + @Test // GH-3758 + void detectsPositionalInParameterBindingsAndExpressions() { + + String queryString = "select u from User u where foo = ?#{bar} and bar = ?3 and baz = ?#{baz}"; + DefaultEntityQuery query = new TestEntityQuery(queryString, true); + + assertThat(query.getQueryString()).isEqualTo("select u from User u where foo = ?1 and bar = ?3 and baz = ?2"); + } + + @Test // GH-3758 + void detectsPositionalInParameterBindingsAndExpressionsWithReuse() { + + String queryString = "select u from User u where foo = ?#{bar} and bar = ?2 and baz = ?#{bar}"; + DefaultEntityQuery query = new TestEntityQuery(queryString, true); + + assertThat(query.getQueryString()).isEqualTo("select u from User u where foo = ?1 and bar = ?2 and baz = ?3"); + } + + @Test // GH-3126 + void countQueryDerivationRetainsNamedExpressionParameters() { + + 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); + + 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 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); + + countQuery = query.deriveCountQuery(null); + + assertThat(countQuery.getParameterBindings()).hasSize(2); + assertThat(countQuery.getParameterBindings()) // + .extracting(ParameterBinding::getOrigin) // + .extracting(ParameterOrigin::isExpression).contains(true, false); + } + + @Test // GH-3126 + void countQueryDerivationRetainsIndexedExpressionParameters() { + + 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); + + 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 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); + + countQuery = query.deriveCountQuery(null); + + assertThat(countQuery.getParameterBindings()).hasSize(2); + assertThat(countQuery.getParameterBindings()) // + .extracting(ParameterBinding::getOrigin) // + .extracting(ParameterOrigin::isExpression).contains(true, false); + } + @Test // DATAJPA-461 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); @@ -329,25 +543,26 @@ 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 bindings = query.getParameterBindings(); assertThat(bindings).hasSize(1); assertPositionalBinding(ParameterBinding.class, 1, bindings.get(0)); - } @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 bindings = query.getParameterBindings(); @@ -362,7 +577,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 bindings = query.getParameterBindings(); @@ -373,7 +589,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 bindings = query.getParameterBindings(); @@ -384,7 +600,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 bindings = query.getParameterBindings(); @@ -395,7 +611,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 bindings = query.getParameterBindings(); @@ -406,7 +622,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 bindings = query.getParameterBindings(); @@ -414,19 +630,21 @@ void detectsInBindingWithSpecialCharactersAndWordCharactersMixedInParentheses() assertNamedBinding(InParameterBinding.class, "ab1babc생일233", bindings.get(0)); } - @Test // DATAJPA-712 + @Test // DATAJPA-712, GH-3619 void shouldReplaceAllNamedExpressionParametersWithInClause() { - StringQuery query = new StringQuery("select a from A a where a.b in :#{#bs} and a.c in :#{#cs}", 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.bar}", true); String queryString = query.getQueryString(); - assertThat(queryString).isEqualTo("select a from A a where a.b in :__$synthetic$__1 and a.c in :__$synthetic$__2"); + assertThat(queryString).isEqualTo( + "select a from A a where a.b in :__$synthetic$__1 and a.c in :__$synthetic$__2 and a.d in :__$synthetic$__3"); } @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(); @@ -434,26 +652,45 @@ void shouldReplaceExpressionWithLikeParameters() { .isEqualTo("select a from A a where a.b LIKE :__$synthetic$__1 and a.c LIKE :__$synthetic$__2"); } - @Test // DATAJPA-712 + @Test // GH-3907 + void considersOnlyDedicatedPositionalBindMarkersAsSuch() { + + DefaultEntityQuery query = new TestEntityQuery( + "select '[\"x\", \"c\"]'::jsonb ?| array[?1]::text[] FROM foo WHERE foo BETWEEN ?1 and ?2", true); + + assertThat(query.getParameterBindings()).hasSize(2); + + query = new TestEntityQuery("select '[\"x\", \"c\"]'::jsonb ?& array[:foo]::text[] FROM foo WHERE foo = :bar", + true); + + assertThat(query.getParameterBindings()).hasSize(2); + } + + @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}", 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"); - assertThat(((Expression) query.getParameterBindings().get(0).getOrigin()).expression()).isEqualTo("#bs"); - assertThat(((Expression) query.getParameterBindings().get(1).getOrigin()).expression()).isEqualTo("#cs"); + assertThat(queryString).isEqualTo("select a from A a where a.b in ?1 and a.c in ?2 and a.d in ?3"); + + assertThat(((Expression) query.getParameterBindings().get(0).getOrigin()).expression().getExpressionString()) + .isEqualTo("#bs"); + assertThat(((Expression) query.getParameterBindings().get(1).getOrigin()).expression().getExpressionString()) + .isEqualTo("#cs"); + assertThat(((Expression) query.getParameterBindings().get(2).getOrigin()).expression().getExpressionString()) + .isEqualTo("${foo}"); } @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(); } /** @@ -464,14 +701,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 bindings = query.getParameterBindings(); assertThat(bindings).isNotEmpty(); @@ -479,7 +718,7 @@ void bindingsMatchQueryForIdenticalSpelExpressions() { for (ParameterBinding binding : bindings) { assertThat(binding.getName()).isNotNull(); assertThat(query.getQueryString()).contains(binding.getName()); - assertThat(((Expression) binding.getOrigin()).expression()).isEqualTo("#exp"); + assertThat(((Expression) binding.getOrigin()).expression().getExpressionString()).isEqualTo("#exp"); } } @@ -490,13 +729,15 @@ void getProjection() { checkProjection("select something from Entity something", "something", "single expression", false); checkProjection("select x, y, z from Entity something", "x, y, z", "tuple", false); - checkProjection("sect x, y, z from Entity something", "", "missing select", false); - checkProjection("select x, y, z fron Entity something", "", "missing from", false); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> checkProjection("sect x, y, z from Entity something", "", "missing select", false)); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> checkProjection("select x, y, z fron Entity something", "", "missing from", false)); } 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); } @@ -520,7 +761,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); } @@ -528,32 +769,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 @@ -573,7 +814,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(); @@ -593,16 +834,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 testQueries = Arrays.asList( // @@ -613,11 +856,11 @@ void makesUsageOfJdbcStyleParameterAvailable() { for (String testQuery : testQueries) { - assertThat(new StringQuery(testQuery, false) // + assertThat(new TestEntityQuery(testQuery, false) // .usesJdbcStyleParameters()) // - .describedAs(testQuery) // - .describedAs(testQuery) // - .isFalse(); + .describedAs(testQuery) // + .describedAs(testQuery) // + .isFalse(); } } @@ -625,7 +868,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(); @@ -645,7 +888,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(); } @@ -662,7 +905,7 @@ void isNotDefaultProjection() { ); for (String queryString : queriesWithDefaultProjection) { - assertThat(new StringQuery(queryString, true).isDefaultProjection()) // + assertThat(new TestEntityQuery(queryString, true).isDefaultProjection()) // .describedAs(queryString) // .isTrue(); } @@ -672,7 +915,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(); @@ -684,7 +927,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) // @@ -695,7 +938,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) // @@ -704,19 +947,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) { + + DeclaredQuery source = DeclaredQuery.jpqlQuery(query); + PreprocessedQuery bindableQuery = PreprocessedQuery.ParameterBindingParser.INSTANCE.parse(query, + source::rewrite, it -> {}); - assertThat(new StringQuery(query, nativeQuery).hasNamedParameter()) // + 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 f1d7e707d8..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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -15,23 +15,53 @@ */ 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.domain.Sort; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.query.ReturnedType; + /** * TCK Tests for {@link DefaultQueryEnhancer}. * * @author Mark Paluch + * @author Alim Naizabek */ -public class DefaultQueryEnhancerUnitTests extends QueryEnhancerTckTests { +class DefaultQueryEnhancerUnitTests extends QueryEnhancerTckTests { @Override - QueryEnhancer createQueryEnhancer(DeclaredQuery declaredQuery) { - return new DefaultQueryEnhancer(declaredQuery); + QueryEnhancer createQueryEnhancer(DeclaredQuery query) { + return new DefaultQueryEnhancer(query); } @Override @Test // GH-2511, GH-2773 @Disabled("Not properly supported by QueryUtils") void shouldDeriveNativeCountQueryWithVariable(String query, String expected) {} + + @Test // GH-3546 + void shouldApplySorting() { + + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery("SELECT e FROM Employee e")); + + 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"); + } + + @Test // GH-3811 + void shouldApplySortingWithNullHandling() { + + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery("SELECT e FROM Employee e")); + + 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/DefaultQueryUtilsUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryUtilsUnitTests.java index cf3eb4867c..0e6b4a577c 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryUtilsUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryUtilsUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkJpa21UtilsTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkJpa21UtilsTests.java index bd97fbb600..19a2701e0e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkJpa21UtilsTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkJpa21UtilsTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkMetaAnnotatedQueryMethodIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkMetaAnnotatedQueryMethodIntegrationTests.java index 7c5d31b1bd..bf8e408d52 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkMetaAnnotatedQueryMethodIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkMetaAnnotatedQueryMethodIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -19,18 +19,18 @@ import jakarta.persistence.EntityManagerFactory; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Properties; import javax.sql.DataSource; import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -58,12 +58,12 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.FileCopyUtils; -import org.springframework.util.FileSystemUtils; /** * Verify that {@link Meta}-annotated methods properly embed comments into EclipseLink queries. * * @author Greg Turnquist + * @author Edoardo Patti * @since 3.0 */ @ExtendWith(SpringExtension.class) @@ -74,16 +74,19 @@ class EclipseLinkMetaAnnotatedQueryMethodIntegrationTests { @Autowired RoleRepositoryWithMeta repository; private static final ResourceLoader RESOURCE_LOADER = new DefaultResourceLoader(); - private static final String LOG_FILE = "test-eclipselink-meta.log"; + private static String LOG_FILE; - @BeforeEach - void cleanoutLogfile() throws IOException { - new FileOutputStream(LOG_FILE).close(); + private static Path logFile; + + @BeforeAll + static void createLogfile() throws IOException { + logFile = Files.createTempFile("test-eclipselink-meta", ".log"); + LOG_FILE = logFile.toAbsolutePath().toString(); } @AfterAll - static void deleteLogfile() throws IOException { - FileSystemUtils.deleteRecursively(Path.of(LOG_FILE)); + static void deleteLogFile() { + logFile.toFile().deleteOnExit(); } @Test // GH-775 diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkParameterMetadataProviderIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkParameterMetadataProviderIntegrationTests.java index c89f75309f..1e253c5acb 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkParameterMetadataProviderIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkParameterMetadataProviderIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkQueryUtilsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkQueryUtilsIntegrationTests.java index f70c7186f7..0b18feba5c 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkQueryUtilsIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkQueryUtilsIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -15,11 +15,30 @@ */ package org.springframework.data.jpa.repository.query; +import static org.assertj.core.api.Assertions.*; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Root; + +import java.util.List; + +import org.eclipse.persistence.internal.jpa.EJBQueryImpl; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.mapping.PropertyPath; import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; /** + * EclipseLink variant of {@link QueryUtilsIntegrationTests}. + * * @author Oliver Gierke * @author Jens Schauder + * @author Mark Paluch */ @ContextConfiguration("classpath:eclipselink.xml") class EclipseLinkQueryUtilsIntegrationTests extends QueryUtilsIntegrationTests { @@ -28,4 +47,64 @@ int getNumberOfJoinsAfterCreatingAPath() { return 1; } + @Test // GH-2756 + @Override + void prefersFetchOverJoin() { + + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(User.class); + Root from = query.from(User.class); + from.fetch("manager"); + from.join("manager"); + + PropertyPath managerFirstname = PropertyPath.from("manager.firstname", User.class); + PropertyPath managerLastname = PropertyPath.from("manager.lastname", User.class); + + QueryUtils.toExpressionRecursively(from, managerLastname); + Path expr = (Path) QueryUtils.toExpressionRecursively(from, managerFirstname); + + assertThat(expr.getParentPath()).hasFieldOrPropertyWithValue("isFetch", true); + assertThat(from.getFetches()).hasSize(1); + assertThat(from.getJoins()).hasSize(1); + } + + @Test // GH-3349 + @Disabled + @Override + void doesNotCreateJoinForRelationshipSimpleId() { + // eclipse link produces join for path.get(relationship) + } + + @Test // GH-3349 + @Disabled + @Override + void doesNotCreateJoinForRelationshipEmbeddedId() { + // eclipse link produces join for path.get(relationship) + } + + @Test // GH-3349 + @Disabled + @Override + void doesNotCreateJoinForRelationshipIdClass() { + // eclipse link produces join for path.get(relationship) + } + + @Test // GH-3983, GH-2870 + @Disabled("Not supported by EclipseLink") + @Transactional + @Override + void applyAndBindOptimizesIn() {} + + @Test // GH-3983, GH-2870 + @Transactional + @Override + void applyAndBindExpandsToPositionalPlaceholders() { + + em.getCriteriaBuilder(); + EJBQueryImpl query = (EJBQueryImpl) QueryUtils.applyAndBind("DELETE FROM User u", + List.of(new User(), new User()), em.unwrap(null)); + + assertThat(query.getDatabaseQuery().getJPQLString()).isEqualTo("DELETE FROM User u where u = ?1 or u = ?2"); + } + } 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 124de35a33..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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -16,18 +16,19 @@ package org.springframework.data.jpa.repository.query; import static org.assertj.core.api.Assertions.*; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; 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
      - * 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)}. - *
      + * 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)}.
      * IMPORTANT: Purely verifies the parser without any transformations. * * @author Greg Turnquist @@ -40,14 +41,9 @@ class EqlComplianceTests { */ private static String parseWithoutChanges(String query) { - EqlLexer lexer = new EqlLexer(CharStreams.fromString(query)); - EqlParser parser = new EqlParser(new CommonTokenStream(lexer)); - - parser.addErrorListener(new BadJpqlGrammarErrorListener(query)); - - EqlParser.StartContext parsedQuery = parser.start(); + JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser.parseQuery(query); - return render(new EqlQueryRenderer().visit(parsedQuery)); + return TokenRenderer.render(new EqlQueryRenderer().visit(parser.getContext())); } private void assertQuery(String query) { @@ -78,6 +74,7 @@ void selectClause() { assertQuery("SELECT COUNT(e) FROM Employee e"); assertQuery("SELECT MAX(e.salary) FROM Employee e"); + assertQuery("select sum(i.size.foo.bar.new) from Item i"); assertQuery("SELECT NEW com.acme.reports.EmpReport(e.firstName, e.lastName, e.salary) FROM Employee e"); } @@ -101,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 @@ -121,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() { @@ -197,10 +210,10 @@ 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 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'"); @@ -233,10 +246,10 @@ 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 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'"); @@ -261,10 +274,10 @@ void functionsInOrderBy() { assertQuery("SELECT e FROM Employee e ORDER BY e.salary - 1000"); assertQuery("SELECT e FROM Employee e ORDER BY e.salary + 1000"); - assertQuery("SELECT e FROM Employee e ORDER BY e.salary*2"); - assertQuery("SELECT e FROM Employee e ORDER BY e.salary*2.0"); - assertQuery("SELECT e FROM Employee e ORDER BY e.salary/2"); - assertQuery("SELECT e FROM Employee e ORDER BY e.salary/2.0"); + assertQuery("SELECT e FROM Employee e ORDER BY e.salary * 2"); + assertQuery("SELECT e FROM Employee e ORDER BY e.salary * 2.0"); + assertQuery("SELECT e FROM Employee e ORDER BY e.salary / 2"); + assertQuery("SELECT e FROM Employee e ORDER BY e.salary / 2.0"); assertQuery("SELECT e FROM Employee e ORDER BY ABS(e.salary - e.manager.salary)"); assertQuery("SELECT e FROM Employee e ORDER BY COALESCE(e.salary, 0)"); assertQuery("SELECT e FROM Employee e ORDER BY CONCAT(e.firstName, ' ', e.lastName)"); @@ -290,10 +303,10 @@ void functionsInGroupBy() { assertQuery("SELECT e FROM Employee e GROUP BY e.salary - 1000"); assertQuery("SELECT e FROM Employee e GROUP BY e.salary + 1000"); - assertQuery("SELECT e FROM Employee e GROUP BY e.salary*2"); - assertQuery("SELECT e FROM Employee e GROUP BY e.salary*2.0"); - assertQuery("SELECT e FROM Employee e GROUP BY e.salary/2"); - assertQuery("SELECT e FROM Employee e GROUP BY e.salary/2.0"); + assertQuery("SELECT e FROM Employee e GROUP BY e.salary * 2"); + assertQuery("SELECT e FROM Employee e GROUP BY e.salary * 2.0"); + assertQuery("SELECT e FROM Employee e GROUP BY e.salary / 2"); + assertQuery("SELECT e FROM Employee e GROUP BY e.salary / 2.0"); assertQuery("SELECT e FROM Employee e GROUP BY ABS(e.salary - e.manager.salary)"); assertQuery("SELECT e FROM Employee e GROUP BY COALESCE(e.salary, 0)"); assertQuery("SELECT e FROM Employee e GROUP BY CONCAT(e.firstName, ' ', e.lastName)"); @@ -319,10 +332,10 @@ void functionsInHaving() { assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary - 1000 > 0"); assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary + 1000 > 0"); - assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary*2 > 0"); - assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary*2.0 > 0.0"); - assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary/2 > 0"); - assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary/2.0 > 0.0"); + assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary * 2 > 0"); + assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary * 2.0 > 0.0"); + assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary / 2 > 0"); + assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING e.salary / 2.0 > 0.0"); assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING ABS(e.salary - e.manager.salary) > 0"); assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING COALESCE(e.salary, 0) > 0"); assertQuery("SELECT e FROM Employee e GROUP BY e.salary HAVING CONCAT(e.firstName, ' ', e.lastName) = 'Bilbo'"); @@ -348,8 +361,11 @@ 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 SIZE(e.managedEmployees.new) < 2"); assertQuery("SELECT e FROM Employee e WHERE e.managedEmployees IS EMPTY"); + assertQuery("SELECT e FROM Employee e WHERE e.managedEmployee.size.new IS EMPTY"); assertQuery("SELECT e FROM Employee e WHERE 'write code' MEMBER OF e.responsibilities"); + assertQuery("SELECT e FROM Employee e WHERE 'write code' MEMBER OF e.responsibilities.size"); assertQuery("SELECT p FROM Project p WHERE TYPE(p) = LargeProject"); /** @@ -414,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 new file mode 100644 index 0000000000..967af726e6 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java @@ -0,0 +1,40 @@ +/* + * 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 org.antlr.v4.runtime.tree.ParseTreeVisitor; + +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.QueryMethod; + +/** + * Unit tests for {@link DtoProjectionTransformerDelegate}. + * + * @author Mark Paluch + */ +class EqlDtoQueryTransformerUnitTests extends AbstractDtoQueryTransformerUnitTests { + + @Override + JpaQueryEnhancer.EqlQueryParser parse(String query) { + return JpaQueryEnhancer.EqlQueryParser.parseQuery(query); + } + + @Override + ParseTreeVisitor getTransformer(JpaQueryEnhancer.EqlQueryParser parser, QueryMethod method) { + return new EqlSortedQueryTransformer(Sort.unsorted(), parser.getQueryInformation(), + method.getResultProcessor().getReturnedType()); + } +} 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 4f6752c175..635c858e0f 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -21,18 +21,18 @@ import org.junit.jupiter.params.provider.MethodSource; /** - * TCK Tests for {@link EqlQueryParser} mixed into {@link JpaQueryEnhancer}. + * TCK Tests for {@link JpaQueryEnhancer.EqlQueryParser} mixed into {@link JpaQueryEnhancer}. * * @author Greg Turnquist */ -public class EqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests { +class EqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests { @Override QueryEnhancer createQueryEnhancer(DeclaredQuery query) { - assumeThat(query.isNativeQuery()).isFalse(); + assumeThat(query.isNative()).describedAs("EQL (non-native) only").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 ec11428a80..7366ff8d90 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -16,12 +16,9 @@ package org.springframework.data.jpa.repository.query; import static org.assertj.core.api.Assertions.*; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; 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; @@ -29,6 +26,8 @@ import org.junit.jupiter.params.provider.MethodSource; 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 JPA spec * https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc
      @@ -37,6 +36,7 @@ * * @author Greg Turnquist * @author Christoph Strobl + * @author Mark Paluch */ class EqlQueryRendererTests { @@ -47,14 +47,9 @@ class EqlQueryRendererTests { */ private static String parseWithoutChanges(String query) { - EqlLexer lexer = new EqlLexer(CharStreams.fromString(query)); - EqlParser parser = new EqlParser(new CommonTokenStream(lexer)); - - parser.addErrorListener(new BadJpqlGrammarErrorListener(query)); - - EqlParser.StartContext parsedQuery = parser.start(); + JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser.parseQuery(query); - return render(new EqlQueryRenderer().visit(parsedQuery)); + return TokenRenderer.render(new EqlQueryRenderer().visit(parser.getContext())); } static Stream reservedWords() { @@ -74,6 +69,281 @@ 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"); + assertQuery("SELECT e FROM Employee e JOIN FETCH e.address ORDER BY city"); + } + + @Test + void leftJoin() { + assertQuery("SELECT e FROM Employee e LEFT JOIN e.address a ORDER BY a.city"); + } + + @Test // GH-3902 + void fromCollection() { + + assertQuery("SELECT e FROM Employee e, IN(e.projects) AS p"); + assertQuery("SELECT e FROM Employee e, IN(e.projects) p"); + assertQuery("SELECT e FROM Employee e, IN(e.projects)"); + + assertQuery("FROM Employee e, IN(e.projects)"); + } + + @Test // GH-3902 + void fromSubquery() { + + assertQuery("SELECT e FROM Employee e, (SELECT p FROM Project p) AS sub"); + assertQuery("SELECT e FROM Employee e, (SELECT p FROM Project p) sub"); + assertQuery("SELECT e FROM Employee e, (SELECT p FROM Project p)"); + assertQuery("FROM Employee e, (SELECT p FROM Project p) sub"); + } + + @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 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 */ @@ -295,7 +565,7 @@ void fromClauseDowncastingExample1() { assertQuery(""" SELECT b.name, b.ISBN FROM Order o JOIN TREAT(o.product AS Book) b - """); + """); } @Test @@ -304,7 +574,7 @@ void fromClauseDowncastingExample2() { assertQuery(""" SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp WHERE lp.budget > 1000 - """); + """); } /** @@ -319,7 +589,7 @@ void fromClauseDowncastingExample3_SPEC_BUG() { WHERE TREAT(p AS LargeProject).budget > 1000 OR TREAT(p AS SmallProject).name LIKE 'Persist%' OR p.description LIKE "cost overrun" - """); + """); } @Test @@ -330,7 +600,7 @@ void fromClauseDowncastingExample3fixed() { WHERE TREAT(p AS LargeProject).budget > 1000 OR TREAT(p AS SmallProject).name LIKE 'Persist%' OR p.description LIKE 'cost overrun' - """); + """); } @Test @@ -340,7 +610,48 @@ void fromClauseDowncastingExample4() { SELECT e FROM Employee e WHERE TREAT(e AS Exempt).vacationDays > 10 OR TREAT(e AS Contractor).hours > 100 - """); + """); + } + + @Test // GH-3024, GH-3863 + void casting() { + + assertQuery(""" + select cast(i as string) from Item i where cast(i.date as date) <= cast(:currentDateTime as date) + """); + assertQuery("SELECT e FROM Employee e WHERE CAST(e.salary NUMERIC(10, 2)) > 0.0"); + } + + @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 @@ -404,7 +715,7 @@ void allExample() { WHERE emp.salary > ALL (SELECT m.salary FROM Manager m WHERE m.department = emp.department) - """); + """); } @Test @@ -416,7 +727,7 @@ void existsSubSelectExample2() { WHERE EXISTS (SELECT spouseEmp FROM Employee spouseEmp WHERE spouseEmp = emp.spouse) - """); + """); } @Test @@ -435,7 +746,7 @@ void subselectNumericComparisonExample2() { assertQuery(""" SELECT goodCustomer FROM Customer goodCustomer - WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed)/2.0 FROM Customer c) + WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed) / 2.0 FROM Customer c) """); } @@ -454,18 +765,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 @@ -480,11 +786,11 @@ 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 + 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 @@ -493,11 +799,11 @@ 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 + CASE e.rating WHEN 1 THEN e.salary * 1.1 + WHEN 2 THEN e.salary * 1.05 + ELSE e.salary * 1.01 END - """); + """); } @Test @@ -537,7 +843,7 @@ void inClauseWithTypeLiteralsShouldWork() { SELECT e FROM Employee e WHERE TYPE(e) IN (Exempt, Contractor) - """); + """); } @Test @@ -560,6 +866,18 @@ WHERE TYPE(e) IN :empTypes """); } + @Test + void inClauseWithFunctionAndLiterals() { + + assertQuery(""" + select f from FooEntity f where upper(f.name) IN ('Y', 'Basic', 'Remit') + """); + assertQuery( + """ + select count(f) from FooEntity f where f.status IN (com.example.eql_bug_check.entity.FooStatus.FOO, com.example.eql_bug_check.entity.FooStatus.BAR) + """); + } + @Test void notEqualsForTypeShouldWork() { @@ -590,6 +908,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 @@ -734,7 +1060,7 @@ void orderByThatMatchesSelectClauseShouldWork() { void orderByThatMatchesAllSelectAliasesShouldWork() { assertQuery(""" - SELECT o.quantity, o.cost*1.08 AS taxedCost, a.zipcode + 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 @@ -928,6 +1254,14 @@ void findOrdersThatHaveProductNamedByAParameter() { """); } + @Test // GH-4013 + void minMaxFunctionsShouldWork() { + assertQuery("SELECT MAX(e.age), e.address.city FROM Employee e"); + assertQuery("SELECT MAX(1), e.address.city FROM Employee e"); + assertQuery("SELECT MAX(MIN(MOD(e.salary, 10))), e.address.city FROM Employee e"); + assertQuery("SELECT MIN(MOD(e.salary, 10)), e.address.city FROM Employee e"); + } + @Test // GH-2982 void floorShouldBeValidEntityName() { @@ -967,11 +1301,26 @@ void alternateNotEqualsOperatorShouldWork() { assertQuery("select e from Employee e where e.firstName != :name"); } + @Test + void regexShouldWork() { + assertQuery("select e from Employee e where e.lastName REGEXP '^Dr\\.*'"); + } + @Test // GH-3092 void dateAndFromShouldBeValidNames() { assertQuery("SELECT e FROM Entity e WHERE e.embeddedId.date BETWEEN :from AND :to"); } + @Test + void betweenStrings() { + assertQuery("SELECT e FROM Entity e WHERE e.embeddedId.date NOT BETWEEN 'a' AND 'b'"); + } + + @Test + void betweenDates() { + assertQuery("SELECT e FROM Entity e WHERE e.embeddedId.date BETWEEN CURRENT_DATE AND CURRENT_TIME"); + } + @Test // GH-3092 void timeShouldBeAValidParameterName() { assertQuery(""" @@ -1002,9 +1351,62 @@ 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", + "select +1 * -100 from User u", "select count(u) * -0.7f from User u", "select count(oi) + (-100) as perc from StockOrderItem oi", "select p from Payment p where length(p.cardNumber) between +16 and -20" }) void signedLiteralShouldWork(String query) { @@ -1012,11 +1414,19 @@ void signedLiteralShouldWork(String query) { } @ParameterizedTest // GH-3342 - @ValueSource(strings = { "select -count(u) from User u", "select +1*(-count(u)) from User u" }) + @ValueSource(strings = { "select -count(u) from User u", "select +1 * (-count(u)) from User u" }) void signedExpressionsShouldWork(String query) { assertQuery(query); } + @Test // GH-3873 + void escapeClauseShouldWork() { + assertQuery("select t.name from SomeDbo t where t.name LIKE :name escape '\\\\'"); + assertQuery("SELECT e FROM SampleEntity e WHERE LOWER(e.label) LIKE LOWER(?1) ESCAPE '\\\\'"); + assertQuery("SELECT e FROM SampleEntity e WHERE LOWER(e.label) LIKE LOWER(?1) ESCAPE ?1"); + assertQuery("SELECT e FROM SampleEntity e WHERE LOWER(e.label) LIKE LOWER(?1) ESCAPE :param"); + } + @ParameterizedTest // GH-3451 @MethodSource("reservedWords") void entityNameWithPackageContainingReservedWord(String reservedWord) { @@ -1024,4 +1434,46 @@ void entityNameWithPackageContainingReservedWord(String reservedWord) { String source = "select new com.company.%s.thing.stuff.ClassName(e.id) from Experience e".formatted(reservedWord); assertQuery(source); } + + @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-3834 + void reservedWordsShouldWork() { + + assertQuery("select ie from ItemExample ie left join ie.object io where io.externalId = :externalId"); + assertQuery("select ie.object from ItemExample ie left join ie.object io where io.externalId = :externalId"); + assertQuery("select ie from ItemExample ie left join ie.object io where io.object = :externalId"); + assertQuery("select ie from ItemExample ie where ie.status = com.app.domain.object.Status.UP"); + assertQuery("select f from FooEntity f where upper(f.name) IN :names"); + assertQuery("select f from FooEntity f where f.size IN :sizes"); + } + + @Test // GH-3902 + void queryWithoutSelectShouldWork() { + + assertQuery("from Person p"); + assertQuery("from Person p WHERE p.name = 'John' ORDER BY p.name"); + } + + @Test // GH-3902 + void queryWithoutSelectAndIdentificationVariableShouldWork() { + + assertQuery("from Person"); + assertQuery("from Person WHERE name = 'John' ORDER BY name"); + assertQuery("from Person JOIN department WHERE name = 'John' ORDER BY name"); + } + + @Test // GH-3902 + void queryWithoutIdentificationVariableShouldWork() { + + assertQuery("SELECT name, lastname from Person"); + assertQuery("SELECT name, lastname from Person WHERE lastname = 'Doe' ORDER BY name, lastname"); + assertQuery("SELECT name, lastname from Person JOIN department"); + } + } 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 2b5e052527..520039d70b 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -20,19 +20,24 @@ 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; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.dao.InvalidDataAccessApiUsageException; 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 {@link EqlQueryParser}. + * Verify that EQL queries are properly transformed through the {@link JpaQueryEnhancer} and the + * {@link JpaQueryEnhancer.EqlQueryParser}. * * @author Greg Turnquist + * @author Mark Paluch */ class EqlQueryTransformerTests { @@ -70,6 +75,21 @@ void applyingSortShouldCreateAdditionalOrderByCriteria() { assertThat(results).contains("ORDER BY e.role, e.hire_date, e.first_name asc, e.last_name asc"); } + @Test // GH-1280 + void nullFirstLastSorting() { + + // given + var original = "SELECT e FROM Employee e where e.name = :name ORDER BY e.first_name asc NULLS FIRST"; + + assertThat(createQueryFor(original, Sort.unsorted())).isEqualTo(original); + + assertThat(createQueryFor(original, Sort.by(Order.desc("lastName").nullsLast()))).startsWith(original) + .endsWithIgnoringCase("e.lastName DESC NULLS LAST"); + + assertThat(createQueryFor(original, Sort.by(Order.desc("lastName").nullsFirst()))).startsWith(original) + .endsWithIgnoringCase("e.lastName DESC NULLS FIRST"); + } + @Test void applyCountToSimpleQuery() { @@ -83,6 +103,32 @@ void applyCountToSimpleQuery() { assertThat(results).isEqualTo("SELECT count(e) FROM Employee e where e.name = :name"); } + @Test // GH-3902 + void applyCountToFromQuery() { + + // given + var original = "FROM Employee e where e.name = :name"; + + // when + var results = createCountQueryFor(original); + + // then + assertThat(results).isEqualTo("select count(e) FROM Employee e where e.name = :name"); + } + + @Test // GH-3902 + void applyCountToFromQueryWithoutIdentificationVariable() { + + // given + var original = "FROM Employee where name = :name"; + + // when + var results = createCountQueryFor(original); + + // then + assertThat(results).isEqualTo("select count(__) FROM Employee AS __ where name = :name"); + } + @Test void applyCountToMoreComplexQuery() { @@ -96,8 +142,14 @@ void applyCountToMoreComplexQuery() { assertThat(results).isEqualTo("SELECT count(e) FROM Employee e where e.name = :name"); } + @Test // GH-3902 + void usesPrimaryAliasOfMultiselectForCountQuery() { + assertCountQuery("SELECT e.foo, e.bar FROM Employee e where e.name = :name ORDER BY e.modified_date", + "SELECT count(e) FROM Employee e where e.name = :name"); + } + @Test - void applyCountToAlreadySorteQuery() { + void applyCountToAlreadySortedQuery() { // given var original = "SELECT e FROM Employee e where e.name = :name ORDER BY e.modified_date"; @@ -122,8 +174,14 @@ void multipleAliasesShouldBeGathered() { assertThat(results).isEqualTo("select e from Employee e join e.manager m"); } - @Test + @Test // GH-3902 void createsCountQueryCorrectly() { + + assertCountQuery("SELECT id FROM Person", "SELECT count(id) FROM Person"); + assertCountQuery("SELECT p.id FROM Person p", "SELECT count(p) FROM Person p"); + assertCountQuery("SELECT id FROM Person p", "SELECT count(p) FROM Person p"); + assertCountQuery("SELECT id, name FROM Person", "SELECT count(id) FROM Person"); + assertCountQuery("SELECT id, name FROM Person p", "SELECT count(p) FROM Person p"); assertCountQuery(QUERY, COUNT_QUERY); } @@ -162,6 +220,14 @@ void createsCountQueryForQueriesWithSubSelects() { "select count(u) from User u left outer join u.roles r where r in (select r from Role r)"); } + @Test // GH-3902 + void createsCountQueryForQueriesWithoutVariableWithSubSelectsSelectQuery() { + + assertCountQuery( + "select name, (select foo from bar b) from User left outer join u.roles r where r in (select r from Role r)", + "select count(name) from User left outer join u.roles r where r in (select r from Role r)"); + } + @Test void createsCountQueryForAliasesCorrectly() { assertCountQuery("select u from User as u", "select count(u) from User as u"); @@ -172,7 +238,7 @@ void allowsShortJpaSyntax() { assertCountQuery(SIMPLE_QUERY, COUNT_QUERY); } - @Test // GH-2260 + @Test // GH-2260, GH-3902 void detectsAliasCorrectly() { assertThat(alias(QUERY)).isEqualTo("u"); @@ -188,7 +254,12 @@ void detectsAliasCorrectly() { assertThat(alias("select u from User u where not exists (select u2 from User u2)")).isEqualTo("u"); assertThat(alias( "select u from User u where not exists (select u2 from User u2 where not exists (select u3 from User u3))")) - .isEqualTo("u"); + .isEqualTo("u"); + assertThat(alias("select u, (select u2 from User u2) from User u")).isEqualTo("u"); + assertThat(alias("select firstname from User where not exists (select u2 from User u2)")).isNull(); + assertThat(alias("select firstname from User UNION select lastname from User b")).isNull(); + assertThat(alias("select firstname from User UNION select lastname from User UNION select lastname from User b")) + .isNull(); } @Test // GH-2557 @@ -202,13 +273,15 @@ void applySortingAccountsForNewlinesInSubselect() { where exists (select u2 from user u2 ) - """).applySorting(sort)).isEqualToIgnoringWhitespace(""" - select u - from user u - where exists (select u2 - from user u2 - ) - order by u.age desc"""); + """).rewrite(new DefaultQueryRewriteInformation(sort, + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())))) + .isEqualToIgnoringWhitespace(""" + select u + from user u + where exists (select u2 + from user u2 + ) + order by u.age desc"""); } @Test // GH-2563 @@ -349,7 +422,7 @@ void detectsComplexConstructorExpression() { 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 group by ip.id, ip.name, lp.accountId order by ip.name ASC""")) - .isTrue(); + .isTrue(); } @Test // DATAJPA-938 @@ -441,7 +514,7 @@ void doesNotPrefixAliasedFunctionCallNameWithDots() { String query = "SELECT AVG(m.price) AS m.avg FROM Magazine m"; Sort sort = Sort.by("m.avg"); - assertThatIllegalArgumentException().isThrownBy(() -> createQueryFor(query, sort)); + assertThatExceptionOfType(BadJpqlGrammarException.class).isThrownBy(() -> createQueryFor(query, sort)); } @Test // DATAJPA-965, DATAJPA-970, GH-2863 @@ -456,8 +529,10 @@ void doesNotPrefixAliasedFunctionCallNameWhenQueryStringContainsMultipleWhiteSpa @Test // DATAJPA-1506 void detectsAliasWithGroupAndOrderBy() { - assertThat(alias("select * from User group by name")).isNull(); - assertThat(alias("select * from User order by name")).isNull(); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User group by name")); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User order by name")); assertThat(alias("select u from User u group by name")).isEqualTo("u"); assertThat(alias("select u from User u order by name")).isEqualTo("u"); } @@ -559,8 +634,10 @@ void createCountQuerySupportsLineBreakRightAfterDistinct() { @Test void detectsAliasWithGroupAndOrderByWithLineBreaks() { - assertThat(alias("select * from User group\nby name")).isNull(); - assertThat(alias("select * from User order\nby name")).isNull(); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User group\nby name")); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User order\nby name")); assertThat(alias("select u from User u group\nby name")).isEqualTo("u"); assertThat(alias("select u from User u order\nby name")).isEqualTo("u"); assertThat(alias("select u from User\nu\norder \n by name")).isEqualTo("u"); @@ -583,7 +660,8 @@ void findProjectionClauseWithSubselect() { // This is not a required behavior, in fact the opposite is, // but it documents a current limitation. // to fix this without breaking findProjectionClauseWithIncludedFrom we need a more sophisticated parser. - assertThat(projection("select * from (select x from y)")).isNotEqualTo("*"); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> projection("select * from (select x from y)")); } @Test // DATAJPA-1696 @@ -615,20 +693,6 @@ void countProjectionDistinctQueryIncludesNewLineAfterEntityAndBeforeWhere() { "SELECT count(DISTINCT entity1) FROM Entity1 entity1 LEFT JOIN entity1.entity2 entity2 ON entity1.key = entity2.key where entity1.id = 1799"); } - @Test // GH-3269 - void createsCountQueryUsingAliasCorrectly() { - - assertCountQuery("select distinct 1 as x from Employee e", "select count(distinct 1) from Employee e"); - assertCountQuery("SELECT DISTINCT abc AS x FROM T t", "SELECT count(DISTINCT abc) FROM T t"); - assertCountQuery("select distinct a as x, b as y from Employee e", "select count(distinct a , b) from Employee e"); - assertCountQuery("select distinct sum(amount) as x from Employee e GROUP BY n", - "select count(distinct sum(amount)) from Employee e GROUP BY n"); - assertCountQuery("select distinct a, b, sum(amount) as c, d from Employee e GROUP BY n", - "select count(distinct a, b, sum(amount) , d) from Employee e GROUP BY n"); - assertCountQuery("select distinct a, count(b) as c from Employee e GROUP BY n", - "select count(distinct a, count(b)) from Employee e GROUP BY n"); - } - @Test // GH-2393 void createCountQueryStartsWithWhitespace() { @@ -660,14 +724,44 @@ void countQueryUsesCorrectVariable() { assertThat( createCountQueryFor("SELECT t FROM mytable t WHERE nr = :number AND kon = :kon AND datum >= '2019-01-01'")) - .isEqualTo("SELECT count(t) FROM mytable t WHERE nr = :number AND kon = :kon AND datum >= '2019-01-01'"); + .isEqualTo("SELECT count(t) FROM mytable t WHERE nr = :number AND kon = :kon AND datum >= '2019-01-01'"); assertThat(createCountQueryFor("select s FROM users_statuses s WHERE (user_created_at BETWEEN $1 AND $2)")) .isEqualTo("select count(s) FROM users_statuses s WHERE (user_created_at BETWEEN $1 AND $2)"); assertThat( createCountQueryFor("SELECT us FROM users_statuses us WHERE (user_created_at BETWEEN :fromDate AND :toDate)")) - .isEqualTo("SELECT count(us) FROM users_statuses us WHERE (user_created_at BETWEEN :fromDate AND :toDate)"); + .isEqualTo("SELECT count(us) FROM users_statuses us WHERE (user_created_at BETWEEN :fromDate AND :toDate)"); + } + + @Test // GH-3269 + void createsCountQueryUsingAliasCorrectly() { + + assertCountQuery("select distinct 1 as x from Employee e", "select count(distinct 1) from Employee e"); + assertCountQuery("SELECT DISTINCT abc AS x FROM T t", "SELECT count(DISTINCT abc) FROM T t"); + assertCountQuery("select distinct a as x, b as y from Employee e", "select count(distinct a, b) from Employee e"); + assertCountQuery("select distinct sum(amount) as x from Employee e GROUP BY n", + "select count(distinct sum(amount)) from Employee e GROUP BY n"); + assertCountQuery("select distinct a, b, sum(amount) as c, d from Employee e GROUP BY n", + "select count(distinct a, b, sum(amount), d) from Employee e GROUP BY n"); + assertCountQuery("select distinct a, count(b) as c from Employee e GROUP BY n", + "select count(distinct a, count(b)) from Employee e GROUP BY n"); + } + + @Test // GH-3902 + void createsCountQueryWithoutAlias() { + + assertCountQuery( + "SELECT this.quantity FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'", + "SELECT count(this.quantity) FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'"); + } + + @Test // GH-3902 + void createsCountQueryFromMultiselectWithoutAlias() { + + assertCountQuery( + "SELECT this.quantity, that.quantity FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'", + "SELECT count(this.quantity) FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'"); } @Test // GH-2496, GH-2522, GH-2537, GH-2045 @@ -713,7 +807,7 @@ void queryParserPicksCorrectAliasAmidstMultipleAliases() { @MethodSource("queriesWithReservedWordsAsIdentifiers") // GH-2864 void usingReservedWordAsRelationshipNameShouldWork(String relationshipName, String joinAlias) { - EqlQueryParser.parseQuery(String.format(""" + JpaQueryEnhancer.EqlQueryParser.parseQuery(String.format(""" select u from UserAccountEntity u join u.lossInspectorLimitConfiguration lil @@ -753,6 +847,17 @@ 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') UNION SELECT tb FROM Test tb WHERE (tb.type='C')"; + 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') " // + + "UNION SELECT tb FROM Test tb WHERE (tb.type = 'C') order by tb.Type asc"); + } + static Stream queriesWithReservedWordsAsIdentifiers() { return Stream.of( // @@ -768,7 +873,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) { @@ -792,6 +898,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 b2187ef6fc..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlSpecificationTests.java +++ /dev/null @@ -1,887 +0,0 @@ -/* - * Copyright 2023-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 static org.assertj.core.api.Assertions.*; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -/** - * 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
      - *
      - * IMPORTANT: Purely verifies the parser without any transformations. - * - * @author Greg Turnquist - */ -class EqlSpecificationTests { - - private static final String SPEC_FAULT = "Disabled due to spec fault> "; - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example - */ - @Test - void joinExample1() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE e.contactInfo.address.zipcode = '95054' - """); - } - - @Test - void pathExpressionSyntaxExample1() { - - EqlQueryParser.parseQuery(""" - SELECT DISTINCT l.product - FROM Order AS o JOIN o.lineItems l - """); - } - - @Test - void joinsExample1() { - - EqlQueryParser.parseQuery(""" - SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize - """); - } - - @Test - void joinsExample2() { - - EqlQueryParser.parseQuery(""" - SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInnerExample() { - - EqlQueryParser.parseQuery(""" - SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInExample() { - - EqlQueryParser.parseQuery(""" - SELECT OBJECT(c) FROM Customer c, IN(c.orders) o WHERE c.status = 1 - """); - } - - @Test - void doubleJoinExample() { - - EqlQueryParser.parseQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE c.address.zipcode = '95054' - """); - } - - @Test - void leftJoinExample() { - - EqlQueryParser.parseQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - GROUP BY s.name - """); - } - - @Test - void leftJoinOnExample() { - - EqlQueryParser.parseQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - ON p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinWhereExample() { - - EqlQueryParser.parseQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - WHERE p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinFetchExample() { - - EqlQueryParser.parseQuery(""" - SELECT d - FROM Department d LEFT JOIN FETCH d.employees - WHERE d.deptno = 1 - """); - } - - @Test - void collectionMemberExample() { - - EqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void collectionMemberInExample() { - - EqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o, IN(o.lineItems) l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void fromClauseExample() { - - EqlQueryParser.parseQuery(""" - SELECT o - FROM Order AS o JOIN o.lineItems l JOIN l.product p - """); - } - - @Test - void fromClauseDowncastingExample1() { - - EqlQueryParser.parseQuery(""" - SELECT b.name, b.ISBN - FROM Order o JOIN TREAT(o.product AS Book) b - """); - } - - @Test - void fromClauseDowncastingExample2() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT e FROM Employee e - WHERE TREAT(e AS Exempt).vacationDays > 10 - OR TREAT(e AS Contractor).hours > 100 - """); - } - - @Test - void pathExpressionsNamedParametersExample() { - - EqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE c.status = :stat - """); - } - - @Test - void betweenExpressionsExample() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void memberOfExample() { - - EqlQueryParser.parseQuery(""" - SELECT p - FROM Person p - WHERE 'Joe' MEMBER OF p.nicknames - """); - } - - @Test - void existsSubSelectExample1() { - - EqlQueryParser.parseQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS ( - SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void allExample() { - - EqlQueryParser.parseQuery(""" - SELECT emp - FROM Employee emp - WHERE emp.salary > ALL ( - SELECT m.salary - FROM Manager m - WHERE m.department = emp.department) - """); - } - - @Test - void existsSubSelectExample2() { - - EqlQueryParser.parseQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS ( - SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void subselectNumericComparisonExample1() { - - EqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE (SELECT AVG(o.price) FROM c.orders o) > 100 - """); - } - - @Test - void subselectNumericComparisonExample2() { - - EqlQueryParser.parseQuery(""" - SELECT goodCustomer - FROM Customer goodCustomer - WHERE goodCustomer.balanceOwed < ( - SELECT AVG(c.balanceOwed)/2.0 FROM Customer c) - """); - } - - @Test - void indexExample() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) - """); - } - - @Test - void functionInvocationExampleWithCorrection() { - - EqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE - """); - } - - @Test - void updateCaseExample1() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (Exempt, Contractor) - """); - } - - @Test - void theRest2() { - - EqlQueryParser.parseQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (:empType1, :empType2) - """); - } - - @Test - void theRest3() { - - EqlQueryParser.parseQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN :empTypes - """); - } - - @Test - void theRest4() { - - EqlQueryParser.parseQuery(""" - SELECT TYPE(e) - FROM Employee e - WHERE TYPE(e) <> Exempt - """); - } - - @Test - void theRest5() { - - EqlQueryParser.parseQuery(""" - SELECT c.status, AVG(c.filledOrderCount), COUNT(c) - FROM Customer c - GROUP BY c.status - HAVING c.status IN (1, 2) - """); - } - - @Test - void theRest6() { - - EqlQueryParser.parseQuery(""" - SELECT c.country, COUNT(c) - FROM Customer c - GROUP BY c.country - HAVING COUNT(c) > 30 - """); - } - - @Test - void theRest7() { - - EqlQueryParser.parseQuery(""" - SELECT c, COUNT(o) - FROM Customer c JOIN c.orders o - GROUP BY c - HAVING COUNT(o) >= 5 - """); - } - - @Test - void theRest8() { - - EqlQueryParser.parseQuery(""" - SELECT c.id, c.status - FROM Customer c JOIN c.orders o - WHERE o.count > 100 - """); - } - - @Test - void theRest9() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT o.lineItems FROM Order AS o - """); - } - - @Test - void theRest11() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT e.address AS addr - FROM Employee e - """); - } - - @Test - void theRest14() { - - EqlQueryParser.parseQuery(""" - SELECT AVG(o.quantity) FROM Order o - """); - } - - @Test - void theRest15() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT COUNT(o) FROM Order o - """); - } - - @Test - void theRest17() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - 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(() -> { - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - """); - } - - @Test - void theRest26() { - - EqlQueryParser.parseQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - AND c.orders IS EMPTY - """); - } - - @Test - void theRest27() { - - EqlQueryParser.parseQuery(""" - UPDATE Customer c - SET c.status = 'outstanding' - WHERE c.balance < 10000 - """); - } - - @Test - void theRest28() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - """); - } - - @Test - void theRest30() { - - EqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress.state = 'CA' - """); - } - - @Test - void theRest31() { - - EqlQueryParser.parseQuery(""" - SELECT DISTINCT o.shippingAddress.state - FROM Order o - """); - } - - @Test - void theRest32() { - - EqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - """); - } - - @Test - void theRest33() { - - EqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS NOT EMPTY - """); - } - - @Test - void theRest34() { - - EqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void theRest35() { - - EqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.shipped = FALSE - """); - } - - @Test - void theRest36() { - - EqlQueryParser.parseQuery(""" - 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() { - - EqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress <> o.billingAddress - """); - } - - @Test - void theRest38() { - - EqlQueryParser.parseQuery(""" - 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/EscapeCharacterUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EscapeCharacterUnitTests.java index 418fbff3e0..ca16538f7d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EscapeCharacterUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EscapeCharacterUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2024 the original author or authors. + * Copyright 2019-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessorUnitTests.java index 6ec33ba71f..b43e164a37 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessorUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HibernateJpaParametersParameterAccessorUnitTests.java @@ -22,7 +22,7 @@ * @author Cedomir Igaly */ @ExtendWith(SpringExtension.class) -@ContextConfiguration("classpath:hjppa-test.xml") +@ContextConfiguration("classpath:infrastructure.xml") class HibernateJpaParametersParameterAccessorUnitTests { @Autowired private EntityManager em; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlDtoQueryTransformerUnitTests.java new file mode 100644 index 0000000000..4ac9f6a9c5 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlDtoQueryTransformerUnitTests.java @@ -0,0 +1,41 @@ +/* + * 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 org.antlr.v4.runtime.tree.ParseTreeVisitor; + +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.QueryMethod; + +/** + * Unit tests for {@link DtoProjectionTransformerDelegate}. + * + * @author Mark Paluch + */ +class HqlDtoQueryTransformerUnitTests extends AbstractDtoQueryTransformerUnitTests { + + @Override + JpaQueryEnhancer.HqlQueryParser parse(String query) { + return JpaQueryEnhancer.HqlQueryParser.parseQuery(query); + } + + @Override + ParseTreeVisitor getTransformer(JpaQueryEnhancer.HqlQueryParser parser, QueryMethod method) { + return new HqlSortedQueryTransformer(Sort.unsorted(), parser.getQueryInformation(), + method.getResultProcessor().getReturnedType()); + } + +} 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..adf3890db0 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlOrderExpressionVisitorUnitTests.java @@ -0,0 +1,283 @@ +/* + * 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.*; + +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)"), "var_1")) + .startsWithIgnoringCase("order by character_length(var_1.firstname) asc"); + assertThat(renderOrderBy(JpaSort.unsafe("char_length(firstname)"), "var_1")) + .startsWithIgnoringCase("order by char_length(var_1.firstname) asc"); + + assertThat(renderOrderBy(JpaSort.unsafe("nlssort(firstname, 'NLS_SORT = XGERMAN_DIN_AI')"), "var_1")) + .startsWithIgnoringCase("order by nlssort(var_1.firstname, 'NLS_SORT = XGERMAN_DIN_AI')"); + } + + @Test // GH-3172 + void cast() { + + assertThatExceptionOfType(UnsupportedOperationException.class) + .isThrownBy(() -> renderOrderBy(JpaSort.unsafe("cast(emailAddress as date)"), "var_1")); + } + + @Test // GH-3172 + void extract() { + + assertThat(renderOrderBy(JpaSort.unsafe("EXTRACT(DAY FROM createdAt)"), "var_1")) + .startsWithIgnoringCase("order by extract(day from var_1.createdAt)"); + + assertThat(renderOrderBy(JpaSort.unsafe("WEEK(createdAt)"), "var_1")) + .startsWithIgnoringCase("order by extract(week from var_1.createdAt)"); + } + + @Test // GH-3172 + void trunc() { + assertThat(renderOrderBy(JpaSort.unsafe("TRUNC(age)"), "var_1")) + .startsWithIgnoringCase("order by trunc(var_1.age)"); + } + + @Test // GH-3172 + void upperLower() { + assertThat(renderOrderBy(JpaSort.unsafe("upper(firstname)"), "var_1")) + .startsWithIgnoringCase("order by upper(var_1.firstname)"); + assertThat(renderOrderBy(JpaSort.unsafe("lower(firstname)"), "var_1")) + .startsWithIgnoringCase("order by lower(var_1.firstname)"); + } + + @Test // GH-3172 + void substring() { + assertThat(renderOrderBy(JpaSort.unsafe("substring(emailAddress, 0, 3)"), "var_1")) + .startsWithIgnoringCase("order by substring(var_1.emailAddress, 0, 3) asc"); + assertThat(renderOrderBy(JpaSort.unsafe("substring(emailAddress, 0)"), "var_1")) + .startsWithIgnoringCase("order by substring(var_1.emailAddress, 0) asc"); + } + + @Test // GH-3172 + void repeat() { + assertThat(renderOrderBy(JpaSort.unsafe("repeat('a', 5)"), "var_1")) + .startsWithIgnoringCase("order by repeat('a', 5) asc"); + } + + @Test // GH-3172 + void literals() { + + assertThat(renderOrderBy(JpaSort.unsafe("age + 1"), "var_1")).startsWithIgnoringCase("order by var_1.age + 1"); + assertThat(renderOrderBy(JpaSort.unsafe("age + 1l"), "var_1")).startsWithIgnoringCase("order by var_1.age + 1"); + assertThat(renderOrderBy(JpaSort.unsafe("age + 1L"), "var_1")).startsWithIgnoringCase("order by var_1.age + 1"); + assertThat(renderOrderBy(JpaSort.unsafe("age + 1.1"), "var_1")).startsWithIgnoringCase("order by var_1.age + 1.1"); + assertThat(renderOrderBy(JpaSort.unsafe("age + 1.1f"), "var_1")).startsWithIgnoringCase("order by var_1.age + 1.1"); + assertThat(renderOrderBy(JpaSort.unsafe("age + 1.1bi"), "var_1")) + .startsWithIgnoringCase("order by var_1.age + 1.1"); + assertThat(renderOrderBy(JpaSort.unsafe("age + 1.1bd"), "var_1")) + .startsWithIgnoringCase("order by var_1.age + 1.1"); + assertThat(renderOrderBy(JpaSort.unsafe("age + 0x12"), "var_1")).startsWithIgnoringCase("order by var_1.age + 18"); + } + + @Test // GH-3172 + void temporalLiterals() { + + // JDBC + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {ts '2024-01-01 12:34:56'}"), "var_1")) + .startsWithIgnoringCase("order by var_1.createdAt + '2024-01-01T12:34:56'"); + + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {ts '2012-01-03 09:00:00.000000001'}"), "var_1")) + .startsWithIgnoringCase("order by var_1.createdAt + '2012-01-03T09:00:00.000000001'"); + + // Hibernate NPE + assertThatIllegalArgumentException() + .isThrownBy(() -> renderOrderBy(JpaSort.unsafe("createdAt + {t '12:34:56'}"), "var_1")); + + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {d '2024-01-01'}"), "var_1")) + .startsWithIgnoringCase("order by var_1.createdAt + '2024-01-01'"); + + // JPQL + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {ts 2024-01-01 12:34:56}"), "var_1")) + .startsWithIgnoringCase("order by var_1.createdAt + '2024-01-01T12:34:56'"); + + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {t 12:34:56}"), "var_1")) + .startsWithIgnoringCase("order by var_1.createdAt + '12:34:56'"); + + assertThat(renderOrderBy(JpaSort.unsafe("createdAt + {d 2024-01-01}"), "var_1")) + .startsWithIgnoringCase("order by var_1.createdAt + '2024-01-01'"); + } + + @Test // GH-3172 + void arithmetic() { + + // Hibernate representation bugs, should be sum(var_1.age) + assertThat(renderOrderBy(JpaSort.unsafe("sum(age)"), "var_1")).startsWithIgnoringCase("order by sum()"); + assertThat(renderOrderBy(JpaSort.unsafe("min(age)"), "var_1")).startsWithIgnoringCase("order by min()"); + assertThat(renderOrderBy(JpaSort.unsafe("max(age)"), "var_1")).startsWithIgnoringCase("order by max()"); + + assertThat(renderOrderBy(JpaSort.unsafe("age"), "var_1")).startsWithIgnoringCase("order by var_1.age"); + assertThat(renderOrderBy(JpaSort.unsafe("age + 1"), "var_1")).startsWithIgnoringCase("order by var_1.age + 1"); + assertThat(renderOrderBy(JpaSort.unsafe("ABS(age) + 1"), "var_1")) + .startsWithIgnoringCase("order by abs(var_1.age) + 1"); + + assertThat(renderOrderBy(JpaSort.unsafe("neg(active)"), "var_1")) + .startsWithIgnoringCase("order by neg(var_1.active)"); + assertThat(renderOrderBy(JpaSort.unsafe("abs(age)"), "var_1")).startsWithIgnoringCase("order by abs(var_1.age)"); + assertThat(renderOrderBy(JpaSort.unsafe("ceiling(age)"), "var_1")) + .startsWithIgnoringCase("order by ceiling(var_1.age)"); + assertThat(renderOrderBy(JpaSort.unsafe("floor(age)"), "var_1")) + .startsWithIgnoringCase("order by floor(var_1.age)"); + assertThat(renderOrderBy(JpaSort.unsafe("round(age)"), "var_1")) + .startsWithIgnoringCase("order by round(var_1.age)"); + + assertThat(renderOrderBy(JpaSort.unsafe("prod(age, 1)"), "var_1")) + .startsWithIgnoringCase("order by prod(var_1.age, 1)"); + assertThat(renderOrderBy(JpaSort.unsafe("prod(age, age)"), "var_1")) + .startsWithIgnoringCase("order by prod(var_1.age, var_1.age)"); + + assertThat(renderOrderBy(JpaSort.unsafe("diff(age, 1)"), "var_1")) + .startsWithIgnoringCase("order by diff(var_1.age, 1)"); + assertThat(renderOrderBy(JpaSort.unsafe("quot(age, 1)"), "var_1")) + .startsWithIgnoringCase("order by quot(var_1.age, 1)"); + assertThat(renderOrderBy(JpaSort.unsafe("mod(age, 1)"), "var_1")) + .startsWithIgnoringCase("order by mod(var_1.age, 1)"); + assertThat(renderOrderBy(JpaSort.unsafe("sqrt(age)"), "var_1")).startsWithIgnoringCase("order by sqrt(var_1.age)"); + assertThat(renderOrderBy(JpaSort.unsafe("exp(age)"), "var_1")).startsWithIgnoringCase("order by exp(var_1.age)"); + assertThat(renderOrderBy(JpaSort.unsafe("ln(age)"), "var_1")).startsWithIgnoringCase("order by ln(var_1.age)"); + } + + @Test // GH-3172 + @Disabled("HHH-19075") + void trim() { + assertThat(renderOrderBy(JpaSort.unsafe("trim(leading '.' from lastname)"), "var_1")) + .startsWithIgnoringCase("order by repeat('a', 5) asc"); + } + + @Test // GH-3172 + void groupedExpression() { + assertThat(renderOrderBy(JpaSort.unsafe("(lastname)"), "var_1")).startsWithIgnoringCase("order by var_1.lastname"); + } + + @Test // GH-3172 + void tupleExpression() { + assertThat(renderOrderBy(JpaSort.unsafe("(firstname, lastname)"), "var_1")) + .startsWithIgnoringCase("order by var_1.firstname, var_1.lastname"); + } + + @Test // GH-3172 + void concat() { + assertThat(renderOrderBy(JpaSort.unsafe("firstname || lastname"), "var_1")) + .startsWithIgnoringCase("order by concat(var_1.firstname, var_1.lastname)"); + } + + @Test // GH-3172 + void pathBased() { + + String query = renderQuery(JpaSort.unsafe("manager.firstname"), "var_1"); + + assertThat(query).contains("from org.springframework.data.jpa.domain.sample.User var_1 left join var_1.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"), "var_1")) + .startsWithIgnoringCase("order by case var_1.firstname when 'Oliver' then 'A' else var_1.firstname end"); + + assertThat(renderOrderBy( + JpaSort.unsafe("case firstname when 'Oliver' then 'A' when 'Joachim' then 'z' else firstname end"), "var_1")) + .startsWithIgnoringCase( + "order by case var_1.firstname when 'Oliver' then 'A' when 'Joachim' then 'z' else var_1.firstname end"); + + assertThat(renderOrderBy(JpaSort.unsafe("case when age < 31 then 'A' else firstname end"), "var_1")) + .startsWithIgnoringCase("order by case when var_1.age < 31 then 'A' else var_1.firstname end"); + + assertThat( + renderOrderBy(JpaSort.unsafe("case when firstname not in ('Oliver', 'Dave') then 'A' else firstname end"), + "var_1")) + .startsWithIgnoringCase( + "order by case when var_1.firstname not in ('Oliver', 'Dave') then 'A' else var_1.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 createQuery(JpaSort sort, String alias) { + + CriteriaQuery query = em.getCriteriaBuilder().createQuery(User.class); + Selection 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 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 f19c4acc78..3f32615e2b 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -21,18 +21,18 @@ import org.junit.jupiter.params.provider.MethodSource; /** - * TCK Tests for {@link HqlQueryParser} mixed into {@link JpaQueryEnhancer}. + * TCK Tests for {@link JpaQueryEnhancer.HqlQueryParser} mixed into {@link JpaQueryEnhancer}. * * @author Greg Turnquist */ -public class HqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests { +class HqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests { @Override QueryEnhancer createQueryEnhancer(DeclaredQuery query) { - assumeThat(query.isNativeQuery()).isFalse(); + assumeThat(query.isNative()).describedAs("HQL (non-native) only").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/HqlParserUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlParserUnitTests.java index 62f194ea66..fb56b657f3 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlParserUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlParserUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. 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 6f617314ba..022983f796 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 @@ -1,11 +1,11 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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 + *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, @@ -16,13 +16,9 @@ package org.springframework.data.jpa.repository.query; import static org.assertj.core.api.Assertions.*; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; 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; import org.junit.jupiter.params.provider.Arguments; @@ -30,33 +26,31 @@ import org.junit.jupiter.params.provider.ValueSource; /** - * 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
      + * Tests built around examples of HQL found in ... and + * ...
      *
      * IMPORTANT: Purely verifies the parser without any transformations. * * @author Greg Turnquist * @author Christoph Strobl + * @author Mark Paluch + * @author Yannick Brandt + * @author Oscar Fanchin * @since 3.1 */ 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) { - - HqlLexer lexer = new HqlLexer(CharStreams.fromString(query)); - HqlParser parser = new HqlParser(new CommonTokenStream(lexer)); + static String parseWithoutChanges(String query) { - parser.addErrorListener(new BadJpqlGrammarErrorListener(query)); + JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser.parseQuery(query); - HqlParser.StartContext parsedQuery = parser.start(); - - return render(new HqlQueryRenderer().visit(parsedQuery)); + QueryTokenStream tokens = new HqlQueryRenderer().visit(parser.getContext()); + return QueryRenderer.from(tokens).render(); } static Stream reservedWords() { @@ -76,33 +70,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 */ @@ -174,300 +141,811 @@ 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 - void joinsExample1() { + @Test // GH-3711, GH-2970 + void entityTypeReference() { assertQuery(""" - SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize + SELECT TYPE(e) + FROM Employee e """); - } - - @Test - void joinsExample2() { assertQuery(""" - SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1 + SELECT TYPE(?0) + FROM Employee e """); - } - @Test - void joinsInnerExample() { + assertQuery(""" + SELECT e + FROM Employee e + WHERE TYPE(e) IN (Exempt, Contractor) + """); assertQuery(""" - SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1 + SELECT e + FROM Employee e + WHERE TYPE(e) IN (:empType1, :empType2) """); - } - @Disabled("Deprecated syntax dating back to EJB-QL prior to EJB 3, required by JPA, never documented in Hibernate") - @Test - void joinsInExample() { + assertQuery(""" + SELECT e + FROM Employee e + WHERE TYPE(e) IN :empTypes + """); assertQuery(""" - SELECT OBJECT(c) FROM Customer c, IN(c.orders) o WHERE c.status = 1 + SELECT TYPE(e) + FROM Employee e + WHERE TYPE(e) <> Exempt """); - } - @Test - void doubleJoinExample() { + assertQuery(""" + SELECT TYPE(e) + FROM Employee e + WHERE TYPE(e) != Exempt + """); assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE c.address.zipcode = '95054' + SELECT TYPE(e) + FROM Employee e + WHERE TYPE(e) ^= Exempt """); } - @Test - void leftJoinExample() { + @Test // GH-3711 + void entityIdReference() { assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - GROUP BY s.name + SELECT ID(e) + FROM Employee e """); - } - - @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 + SELECT ID(e).foo + FROM Employee e """); } - @Test - void leftJoinWhereExample() { + @Test // GH-3711 + void entityNaturalIdReference() { assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - WHERE p.status = 'inStock' - GROUP BY s.name + SELECT NATURALID(e) + FROM Employee e """); - } - - @Test - void leftJoinFetchExample() { assertQuery(""" - SELECT d - FROM Department d LEFT JOIN FETCH d.employees - WHERE d.deptno = 1 + SELECT NATURALID(e).foo + FROM Employee e """); } - @Test - void collectionMemberExample() { + @Test // GH-3711 + void entityVersionReference() { assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.productType = 'office_supplies' + SELECT VERSION(e) + FROM Employee e """); } - @Test - void collectionMemberInExample() { + @Test // GH-3711 + void treatedNavigablePath() { assertQuery(""" - SELECT DISTINCT o - FROM Order o , IN(o.lineItems) l - WHERE l.product.productType = 'office_supplies' + SELECT TREAT(e as Integer).foo + FROM Employee e """); } - @Test - void fromClauseExample() { + @Test // GH-3711 + void collectionValueNavigablePath() { assertQuery(""" - SELECT o - FROM Order AS o JOIN o.lineItems l JOIN l.product p + SELECT ELEMENT(e) + FROM Employee e """); - } - - @Test - void fromClauseDowncastingExample1() { assertQuery(""" - SELECT b.name, b.ISBN - FROM Order o JOIN TREAT(o.product AS Book) b - """); - } + SELECT ELEMENT(e).foo + FROM Employee e + """); - @Test - void fromClauseDowncastingExample2() { + assertQuery(""" + SELECT VALUE(e) + FROM Employee e + """); assertQuery(""" - SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp - WHERE lp.budget > 1000 - """); + SELECT VALUE(e).foo + FROM Employee e + """); } - /** - * @see #fromClauseDowncastingExample3fixed() - */ - @Test - @Disabled(SPEC_FAULT + "Use double-quotes when it should be using single-quotes for a string literal") - void fromClauseDowncastingExample3_SPEC_BUG() { + @Test // GH-3711 + void mapKeyNavigablePath() { 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" - """); - } + SELECT KEY(e) + FROM Employee e + """); - @Test - void fromClauseDowncastingExample3fixed() { + assertQuery(""" + SELECT KEY(e).foo + FROM Employee e + """); 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' - """); + SELECT INDEX(e) + FROM Employee e + """); } - @Test - void fromClauseDowncastingExample4() { + @Test // GH-3711 + void toOneFkReference() { assertQuery(""" - SELECT e FROM Employee e - WHERE TREAT(e AS Exempt).vacationDays > 10 - OR TREAT(e AS Contractor).hours > 100 - """); - } - - @Test - void pathExpressionsNamedParametersExample() { + SELECT FK(e) + FROM Employee e + """); assertQuery(""" - SELECT c - FROM Customer c - WHERE c.status = :stat + SELECT FK(e.foo) + FROM Employee e """); } - @Test - void betweenExpressionsExample() { + @Test // GH-3711 + void indexedPathAccessFragment() { assertQuery(""" - SELECT t - FROM CreditCard c JOIN c.transactionHistory t - WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9 + SELECT e.names[0] + FROM Employee e """); - } - @Test - void isEmptyExample() { + assertQuery(""" + SELECT e.payments[1].id + FROM Employee e + """); assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY + SELECT some_function()[0] + FROM Employee e + """); + + assertQuery(""" + SELECT some_function()[1].id + FROM Employee e """); } - @Test - void memberOfExample() { + @Test // GH-3711 + void slicedPathAccessFragment() { assertQuery(""" - SELECT p - FROM Person p - WHERE 'Joe' MEMBER OF p.nicknames + SELECT e.names[0:1] + FROM Employee e """); - } - @Test - void existsSubSelectExample1() { + assertQuery(""" + SELECT e.payments[1:2].id + FROM Employee e + """); assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) + SELECT some_function()[0:1] + FROM Employee e + """); + + assertQuery(""" + SELECT some_function()[1:2].id + FROM Employee e """); } - @Test - void allExample() { + @Test // GH-3711 + void functionPathContinuation() { assertQuery(""" - SELECT emp - FROM Employee emp - WHERE emp.salary > ALL(SELECT m.salary - FROM Manager m - WHERE m.department = emp.department) - """); + SELECT some_function().foo + FROM Employee e + """); } - @Test - void existsSubSelectExample2() { + @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 DISTINCT emp - FROM Employee emp - WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); + SELECT e FROM Employee e + WHERE FOO(x).bar %s + """.formatted(nullHandling)); } - @Test - void subselectNumericComparisonExample1() { + @Test // GH-3689 + void size() { assertQuery(""" - SELECT c - FROM Customer c - WHERE (SELECT AVG(o.price) FROM c.orders o) > 100 + SELECT e FROM Employee e + WHERE SIZE(x) > 1 """); - } - - @Test - void subselectNumericComparisonExample2() { assertQuery(""" - SELECT goodCustomer - FROM Customer goodCustomer - WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed)/2.0 FROM Customer c) + SELECT e FROM Employee e + WHERE SIZE(e.skills) > 1 """); } - @Test - void indexExample() { + @Test // GH-3689 + void collectionAggregate() { assertQuery(""" - SELECT w.name - FROM Course c JOIN c.studentWaitlist w - WHERE c.name = 'Calculus' - AND INDEX(w) = 0 + SELECT e FROM Employee e + WHERE MAXELEMENT(foo) > MINELEMENT(bar) """); - } - - /** - * @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) + SELECT e FROM Employee e + WHERE MININDEX(foo) > MAXINDEX(bar) """); } - @Test + @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() { + + 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 + void fromClauseDowncastingExample3() { + + 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" + """); + + 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 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 functionInvocationExample() { + + assertQuery(""" + SELECT c + FROM Customer c + WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) + """); + } + + @Test void functionInvocationExampleWithCorrection() { assertQuery(""" @@ -477,17 +955,26 @@ 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 - """); + 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 @@ -496,11 +983,11 @@ 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 - """); + CASE e.rating WHEN 1 THEN e.salary * 1.1 + WHEN 2 THEN e.salary * 1.05 + ELSE e.salary * 1.01 + END + """); } @Test @@ -508,11 +995,11 @@ void selectCaseExample1() { assertQuery(""" SELECT e.name, - CASE TYPE(e) WHEN Exempt THEN 'Exempt' - WHEN Contractor THEN 'Contractor' - WHEN Intern THEN 'Intern' - ELSE 'NonExempt' - END + 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' """); @@ -523,72 +1010,106 @@ 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') + 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() { + void collectionIsEmpty() { assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (Exempt, Contractor) - """); - } - - @Test - void theRest2() { + DELETE + FROM Customer c + WHERE c.status = 'inactive' + AND c.orders IS EMPTY + """); 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() { @@ -622,6 +1143,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() { @@ -753,7 +1286,7 @@ void theRest20() { void theRest21() { assertQuery(""" - SELECT o.quantity, o.cost*1.08 AS taxedCost, a.zipcode + 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 @@ -791,7 +1324,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 """); @@ -940,81 +1473,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'"); + 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 '' " + // " else p.nickName " + // " end " + // "from Person p"); - parseWithoutChanges("select " + // + assertQuery("select " + // " case " + // " when p.nickName is null " + // " then " + // @@ -1026,430 +1586,435 @@ 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, '') " + // + assertQuery("select coalesce(p.nickName, '') " + // "from Person p"); - parseWithoutChanges("select coalesce(p.nickName, p.name, '') " + // + assertQuery("select coalesce(p.nickName, p.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 " + // + "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"); - parseWithoutChanges("select pr.name, ph.number " + // + "or ph.type = :phoneType"); + 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 " + // + " 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), " + // - " avg(c.duration)" + // + " avg(c.duration)," + // + " 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 p " + // + assertQuery("select c " + // + "from Call c " + // + "join c.phone p " + // + "order by p.number " + // + "offset 10 rows " + // + "fetch first 50 rows with ties"); + assertQuery("select p " + // "from Phone p " + // "join fetch p.calls " + // "order by p " + // @@ -1459,56 +2024,104 @@ 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-3882 + void shouldSupportLimitOffset() { + + assertQuery("SELECT si from StockItem si order by si.id LIMIT 10 OFFSET 10 FETCH FIRST 10 ROWS ONLY"); + assertQuery("SELECT si from StockItem si order by si.id LIMIT ? OFFSET ? FETCH FIRST ? ROWS ONLY"); + assertQuery("SELECT si from StockItem si order by si.id LIMIT :l OFFSET :o"); + assertQuery("SELECT si from StockItem si LIMIT :l OFFSET :o"); + assertQuery("SELECT si from StockItem si order by si.id LIMIT :l"); + assertQuery("SELECT si from StockItem si order by si.id OFFSET 1"); + assertQuery("SELECT si from StockItem si LIMIT 1"); + assertQuery("SELECT si from StockItem si OFFSET 1"); + assertQuery("SELECT si from StockItem si FETCH FIRST 1 ROWS ONLY"); + } + + @Test // GH-2964 + void roundFunctionShouldWorkLikeAnyOtherFunction() { + + 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 + void ceilingFunctionShouldWork() { + assertQuery("select ceiling(1.5) from Element a"); } - @Test // GH-2964 - void roundFunctionShouldWorkLikeAnyOtherFunction() { + @Test // GH-3711 + void lnFunctionShouldWork() { + assertQuery("select ln(7.5) from Element a"); + } - 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 - """); - }); + @Test // GH-4013 + void minMaxFunctionsShouldWork() { + assertQuery("SELECT MAX(MIN(MOD(e.salary, 10))), e.address.city FROM Employee e"); + assertQuery("SELECT MIN(MOD(e.salary, 10)), e.address.city FROM Employee e"); } @Test // GH-2981 void cteWithClauseShouldWork() { assertQuery(""" - WITH maxId AS(select max(sr.snapshot.id) snapshotId from SnapshotReference sr + WITH maxId AS (select max(sr.snapshot.id) snapshotId from SnapshotReference sr where sr.id.selectionId = ?1 and sr.enabled - group by sr.userId - ) + group by sr.userId) select sr from maxId m join SnapshotReference sr on sr.snapshot.id = m.snapshotId """); } + @Test // GH-4012 + void cteWithSearch() { + + assertQuery(""" + WITH Tree AS (SELECT o.uuid AS test_uuid FROM DemoEntity o) + SEARCH BREADTH FIRST BY foo ASC NULLS FIRST, bar DESC NULLS LAST SET baz + SELECT test_uuid FROM Tree + """); + } + + @Test // GH-4012 + void cteWithCycle() { + + assertQuery(""" + WITH Tree AS (SELECT o.uuid AS test_uuid FROM DemoEntity o) CYCLE test_uuid SET circular TO true DEFAULT false + SELECT test_uuid FROM Tree + """); + + assertQuery( + """ + WITH Tree AS (SELECT o.uuid AS test_uuid FROM DemoEntity o) CYCLE test_uuid SET circular TO true DEFAULT false USING bar + SELECT test_uuid FROM Tree + """); + } + @Test // GH-2982 void floorShouldBeValidEntityName() { @@ -1541,9 +2154,52 @@ void castFunctionWithFqdnShouldWork() { assertQuery("SELECT o FROM Order o WHERE CAST(:userId AS java.util.UUID) IS NULL OR o.user.id = :userId"); } - @Test // GH-3025 - void durationLiteralsShouldWork() { - assertQuery("SELECT ce.id FROM CalendarEvent ce WHERE (ce.endDate - ce.startDate) > 5 MINUTE"); + @ParameterizedTest // GH-3025 + @ValueSource(strings = { "YEAR", "MONTH", "DAY", "WEEK", "QUARTER", "HOUR", "MINUTE", "SECOND", "NANOSECOND", + "NANOSECOND", "EPOCH" }) + void durationLiteralsShouldWork(String dtField) { + + assertQuery("SELECT ce.id FROM CalendarEvent ce WHERE (ce.endDate - ce.startDate) > 5 %s".formatted(dtField)); + assertQuery( + "SELECT ce.id FROM CalendarEvent ce WHERE ce.text LIKE :text GROUP BY year(cd.date) HAVING (ce.endDate - ce.startDate) > 5 %s" + .formatted(dtField)); + assertQuery("SELECT ce.id as id, cd.startDate + 5 %s AS summedDate FROM CalendarEvent ce".formatted(dtField)); + } + + @Test // GH-3739 + void dateTimeLiterals() { + + 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'}"); + assertQuery("SELECT e FROM Employee e WHERE e.version = {ts'something weird'}"); + assertQuery("SELECT e FROM Employee e WHERE e.version = {ts2012-01-03 09:00:00+1}"); + assertQuery("SELECT e FROM Employee e WHERE e.version = {ts2012-01-03 09:00:00-1}"); + assertQuery("SELECT e FROM Employee e WHERE e.version = {ts2012-01-03 09:00:00+1:00}"); + assertQuery("SELECT e FROM Employee e WHERE e.version = {ts2012-01-03 09:00:00-1:00}"); + + assertQuery("SELECT e FROM Employee e WHERE e.version = OFFSET DATETIME 2012-01-03 09:00:00+1:01"); + assertQuery("SELECT e FROM Employee e WHERE e.version = OFFSET DATETIME 2012-01-03 09:00:00-1:01"); + } + + @Test + void literals() { + + assertQuery("SELECT e FROM Employee e WHERE e.name = 'Bob'"); + assertQuery("SELECT e FROM Employee e WHERE e.names = [e.firstName, e.lastName]"); + 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.gender = org.acme.Gender.MALE"); + assertQuery("UPDATE Employee e SET e.manager = NULL WHERE e.manager = :manager"); + } + + @ParameterizedTest // GH-3711 + @ValueSource(strings = { "1", "1_000", "1L", "1_000L", "1bi", "1.1f", "2.2d", "2.2bd" }) + void numberLiteralsShouldWork(String literal) { + assertQuery(String.format("SELECT %s FROM User u where u.id = %s", literal, literal)); } @Test // GH-3025 @@ -1557,6 +2213,9 @@ void binaryLiteralsShouldWork() { @Test // GH-3040 void escapeClauseShouldWork() { assertQuery("select t.name from SomeDbo t where t.name LIKE :name escape '\\\\'"); + assertQuery("SELECT e FROM SampleEntity e WHERE LOWER(e.label) LIKE LOWER(?1) ESCAPE '\\\\'"); + assertQuery("SELECT e FROM SampleEntity e WHERE LOWER(e.label) LIKE LOWER(?1) ESCAPE ?1"); + assertQuery("SELECT e FROM SampleEntity e WHERE LOWER(e.label) LIKE LOWER(?1) ESCAPE :param"); } @Test // GH-3062, GH-3056 @@ -1566,6 +2225,13 @@ void typeShouldBeAValidParameter() { assertQuery("select te from TestEntity te where te.type = :type"); } + @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-3099 void functionNamesShouldSupportSchemaScoping() { @@ -1573,7 +2239,7 @@ void functionNamesShouldSupportSchemaScoping() { SELECT b FROM MyEntity b WHERE b.status = :status - AND utl_raw.cast_to_varchar2((nlssort(lower(b.name), 'nls_sort=binary_ai'))) LIKE lower(:name) + AND utl_raw.cast_to_varchar2((nlssort(lower(b.name), 'nls_sort=binary_ai'))) LIKE lower(:name) ORDER BY utl_raw.cast_to_varchar2((nlssort(lower(b.name), 'nls_sort=binary_ai'))) ASC """); @@ -1615,6 +2281,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() { @@ -1645,17 +2347,34 @@ group by extract(epoch from departureTime) """); } + @Test // GH-3757 + void arithmeticDate() { + + assertQuery("SELECT a FROM foo a WHERE (cast(a.createdAt as date) - CURRENT_DATE()) BY day - 2 = 0"); + assertQuery("SELECT a FROM foo a WHERE (cast(a.createdAt as date) - CURRENT_DATE()) BY day - 2 = 0"); + assertQuery("SELECT a FROM foo a WHERE (cast(a.createdAt as date)) BY day - 2 = 0"); + + assertQuery("SELECT f.start BY DAY - 2 FROM foo f"); + assertQuery("SELECT f.start - 1 minute FROM foo f"); + + assertQuery("SELECT f FROM foo f WHERE (cast(f.start as date) - CURRENT_DATE()) BY day - 2 = 0"); + assertQuery("SELECT 1 week - 1 day FROM foo f"); + assertQuery("SELECT f.birthday - local date day FROM foo f"); + assertQuery("SELECT local datetime - f.birthday FROM foo f"); + assertQuery("SELECT (1 year) by day FROM foo f"); + } + @ParameterizedTest // GH-3342 @ValueSource( - strings = { "select 1 from User", "select -1 from User", "select +1 from User", "select +1*-100 from User", - "select count(u)*-0.7f from User u", "select count(oi) + (-100) as perc from StockOrderItem oi", + strings = { "select 1 from User", "select -1 from User", "select +1 from User", "select +1 * -100 from User", + "select count(u) * -0.7f from User u", "select count(oi) + (-100) as perc from StockOrderItem oi", "select p from Payment p where length(p.cardNumber) between +16 and -20" }) void signedLiteralShouldWork(String query) { assertQuery(query); } @ParameterizedTest // GH-3342 - @ValueSource(strings = { "select -count(u) from User u", "select +1*(-count(u)) from User u" }) + @ValueSource(strings = { "select -count(u) from User u", "select +1 * (-count(u)) from User u" }) void signedExpressionsShouldWork(String query) { assertQuery(query); } @@ -1667,4 +2386,470 @@ void entityNameWithPackageContainingReservedWord(String reservedWord) { String source = "select new com.company.%s.thing.stuff.ClassName(e.id) from Experience e".formatted(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() { + + assertQuery("select ie from ItemExample ie left join ie.object io where io.externalId = :externalId"); + assertQuery("select ie.object from ItemExample ie left join ie.object io where io.externalId = :externalId"); + assertQuery("select ie from ItemExample ie left join ie.object io where io.object = :externalId"); + assertQuery("select ie from ItemExample ie where ie.status = com.app.domain.object.Status.UP"); + } + + @Test // GH-3864 + void fromSRFWithAlias() { + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date, :integerValue) d + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date) d + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function() d + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date, :integerValue, :longValue) d + """); + } + + @Test // GH-3864 + void fromSRFWithoutAlias() { + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date, :integerValue) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function() + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date, :integerValue, :longValue) + """); + } + + @Test // GH-3864 + void joinEntityToSRFWithFunctionAlias() { + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from EntityClass e join some_function(:date, :integerValue) d on (e.id = d.idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from EntityClass e join some_function(:date) d on (e.id = d.idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from EntityClass e join some_function() d on (e.id = d.idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from EntityClass e join some_function(:date, :integerValue, :longValue) d on (e.id = d.idFunction) + """); + } + + @Test // GH-3864 + void joinEntityToSRFWithoutFunctionAlias() { + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from EntityClass e join some_function(:date, :integerValue) on (e.id = idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from EntityClass e join some_function(:date) on (e.id = idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from EntityClass e join some_function() on (e.id = idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from EntityClass e join some_function(:date, :integerValue, :longValue) on (e.id = idFunction) + """); + } + + @Test // GH-3864 + void joinSRFToEntityWithoutFunctionWithAlias() { + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date, :integerValue) d join EntityClass e on (e.id = d.idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date) d join EntityClass e on (e.id = idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function() d join EntityClass e on (e.id = idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date, :integerValue, :longValue) d join EntityClass e on (e.id = d.idFunction) + """); + } + + @Test // GH-3864 + void joinSRFToEntityWithoutFunctionWithoutAlias() { + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date, :integerValue) join EntityClass e on (e.id = idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date) join EntityClass e on (e.id = idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function() join EntityClass e on (e.id = idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date, :integerValue, :longValue) join EntityClass e on (e.id = idFunction) + """); + } + + @Test // GH-3864 + void selectSRFIntoSubquery() { + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function(:date, :integerValue) x) d + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function(:date) x) d + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function() x) d + """); + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function(:date, :integerValue, :longValue) x) d + """); + } + + @Test // GH-3864 + void joinEntityToSRFIntoSubquery() { + + assertQuery(""" + select new com.example.dto.SampleDto(k.id, d.nameFunction) + from EntityClass k + inner join (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function(:date, :integerValue) x) d on (k.id = d.idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(k.id, d.nameFunction) + from EntityClass k + inner join (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function(:date) x) d on (k.id = d.idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(k.id, d.nameFunction) + from EntityClass k + inner join (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function() x) d on (k.id = d.idFunction) + """); + + assertQuery(""" + select new com.example.dto.SampleDto(k.id, d.nameFunction) + from EntityClass k + inner join (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function(:date, :integerValue, :longValue) x) d on (k.id = d.idFunction) + """); + } + + @Test // GH-3864 + void joinLateralEntityToSRF() { + + assertQuery(""" + select new com.example.dto.SampleDto(k.id, d.nameFunction) + from EntityClass k + join lateral (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function(:date, :integerValue) x where x.idFunction = k.id) d + """); + + assertQuery(""" + select new com.example.dto.SampleDto(k.id, d.nameFunction) + from EntityClass k + join lateral (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function(:date) x where x.idFunction = k.id) d + """); + + assertQuery(""" + select new com.example.dto.SampleDto(k.id, d.nameFunction) + from EntityClass k + join lateral (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function() x where x.idFunction = k.id) d + """); + + assertQuery(""" + select new com.example.dto.SampleDto(k.id, d.nameFunction) + from EntityClass k + join lateral (select x.idFunction idFunction, x.nameFunction nameFunction + from some_function(:date, :integerValue, :longValue) x where x.idFunction = k.id) d + """); + + } + + @Test // GH-3864 + void joinTwoFunctions() { + + assertQuery(""" + select new com.example.dto.SampleDto(d.idFunction, d.nameFunction) + from some_function(:date, :integerValue) d + inner join some_function_single_param(:date) k on (d.idFunction = k.idFunctionSP) + """); + } + + @Test // GH-3902 + void queryWithoutSelectShouldWork() { + + assertQuery("from Person p"); + assertQuery("from Person p WHERE p.name = 'John' ORDER BY p.name"); + } + + @Test // GH-3902 + void queryWithoutSelectAndIdentificationVariableShouldWork() { + + assertQuery("from Person"); + assertQuery("from Person WHERE name = 'John' ORDER BY name"); + assertQuery("from Person JOIN department WHERE name = 'John' ORDER BY name"); + assertQuery( + "from Person JOIN (select phone.number as n, phone.person as pp from Phone phone) WHERE name = 'John' ORDER BY name"); + assertQuery("from Person JOIN (select number, person from Phone) WHERE name = 'John' ORDER BY name"); + } + + @Test // GH-3902 + void queryWithoutIdentificationVariableShouldWork() { + + assertQuery("SELECT name, lastname from Person"); + assertQuery("SELECT name, lastname from Person WHERE lastname = 'Doe' ORDER BY name, lastname"); + assertQuery("SELECT name, lastname from Person JOIN department"); + assertQuery( + "SELECT name, lastname from Person JOIN (select phone.number as n, phone.person as pp from Phone phone) WHERE name = 'John' ORDER BY name"); + assertQuery( + "SELECT name, lastname from Person JOIN (select number, person from Phone) WHERE name = 'John' ORDER BY name"); + } + + @Test // GH-3883 + void jsonArray() { + + assertQuery("select json_array(1, false, 'val1', 'val2' null on null)"); + assertQuery("select json_array(1, false, 'val1', 'val2' absent on null)"); + } + + @Test // GH-3883 + void jsonExists() { + + assertQuery("select json_exists(1, e.foo)"); + assertQuery("select json_exists(e.json, '$.theArray[$idx]' passing 1 as idx ERROR ON ERROR) from Entity e"); + + assertQuery("select json_exists(e.json, '$.theArray[$idx]' passing 1 as idx TRUE ON ERROR) from Entity e"); + + assertQuery("select json_exists(1, e.foo FALSE ON ERROR)"); + } + + @Test // GH-3883 + void jsonObject() { + + assertQuery("select json_object('key', 'value')"); + assertQuery("select json_object('key' VALUE 'value')"); + assertQuery("select json_object(KEY 'key' VALUE 'value')"); + assertQuery( + "select json_object('key1', 'value1', KEY 'key2' VALUE 'value2', 'key3' : 'value3', 'key4', 'value4', KEY 'key5' VALUE 'value5', 'key6' : 'value6')"); + assertQuery("select json_object('key', 'value' absent on null)"); + assertQuery("select json_object('key', 'value' null on null)"); + } + + @Test // GH-3883 + void jsonQuery() { + + assertQuery("select json_query(e.json, '$.theString') from Entity e"); + assertQuery("select json_query(e.json, '$.theString' with wrapper) from Entity e"); + assertQuery("select json_query(e.json, '$.theString' without wrapper) from Entity e"); + assertQuery("select json_query(e.json, '$.theString' without array wrapper) from Entity e"); + assertQuery("select json_query(e.json, '$.theString' with conditional array wrapper) from Entity e"); + assertQuery("select json_query(e.json, '$.theArray[$idx]' passing 1 as idx) from Entity e"); + + assertQuery( + "select json_query(e.json, '$.theString' without array wrapper ERROR ON ERROR EMPTY ARRAY ON EMPTY) from Entity e"); + + assertQuery( + "select json_query(e.json, '$.theString' without array wrapper EMPTY OBJECT ON ERROR NULL ON EMPTY) from Entity e"); + } + + @Test // GH-3883 + void jsonValue() { + + assertQuery("select json_value(e.json, '$.theString') from Entity e"); + assertQuery("select json_value(e.json, '$.theArray[$idx]' passing 1 as idx) from Entity e"); + assertQuery( + "select json_value(e.json, '$.theArray[$idx]' passing 1 as idx RETURNING NUMBER(12, 2) NULL ON ERROR eRRor ON error) from Entity e"); + assertQuery( + "select json_value(e.json, '$.theArray[$idx]' passing 1 as idx DEFAULT 7 ON ERROR NULL ON EMPTY) from Entity e"); + } + + @Test // GH-3883 + void jsonArrayagg() { + + assertQuery("select json_arrayagg(e.theString null on null) from Entity e"); + assertQuery( + "select json_arrayagg(e.theString absent on null order by e.id) FILTER (where foo = bar) from Entity e"); + } + + @Test // GH-3883 + void jsonObjectagg() { + + assertQuery("select json_objectagg(e.theString : e.id) from Entity e"); + assertQuery("select json_objectagg(KEY e.theString VALUE e.id) from Entity e"); + assertQuery("select json_objectagg(e.theString VALUE e.id) from Entity e"); + assertQuery( + "select json_objectagg(foo : bar ABSENT ON NULL WITH UNIQUE KEYS) FILTER (where foo = bar) from Entity e"); + } + + @Test // GH-3883 + void jsonTable() { + + assertQuery(""" + SELECT e FROM from json_table(e.json, '$' + columns(theInt Integer, + theFloat Float, + nonExisting exists) ERROR ON ERROR) + """); + + assertQuery(""" + SELECT e FROM from EntityWithJson e + join lateral json_table(e.json, '$' columns(theInt Integer, + theFloat Float, + theString String, + theBoolean Boolean, + theNull String, + theObject JSON, + theObject JSON WITH UNCONDITIONAL ARRAY WRAPPER ERROR ON EMPTY EMPTY ON ERROR, + theObject JSON ERROR ON EMPTY EMPTY ON ERROR, + theNestedInt Integer path '$.theObject.theInt', + theNestedFloat Float path '$.theObject.theFloat', + theNestedString String path '$.theObject.theString', + nested '$.theArray[*]' columns(arrayIndex for ordinality, + arrayValue String path '$'), + nonExisting exists) ERROR ON ERROR) + """); + } + + @Test // GH-3883 + void xmlElement() { + + assertQuery("select xmlelement(name myelement)"); + assertQuery( + "select xmlelement(name `my-element`, xmlattributes(123 as attr1, '456' as `attr-2`), 'myContent', xmlelement(name empty))"); + } + + @Test // GH-3883 + void xmlForest() { + + assertQuery("select xmlforest(123 as e1)"); + assertQuery("select xmlforest(123 as e1, 'text' as e2)"); + } + + @Test // GH-3883 + void xmlPi() { + + assertQuery("select xmlpi(name php)"); + assertQuery("select xmlpi(name php, foo)"); + } + + @Test // GH-3883 + void xmlQuery() { + + assertQuery("select xmlquery('/a/val' passing 'asd')"); + assertQuery("select xmlquery('/a/val' passing e.xml) from Entity e"); + } + + @Test // GH-3883 + void xmlExists() { + + assertQuery("select xmlexists('/a/val' passing 'asd')"); + assertQuery("select xmlexists('/a/val' passing e.xml) from Entity e"); + } + + @Test // GH-3883 + void xmlAgg() { + + assertQuery("select xmlagg(xmlelement(name a, e.theString))"); + assertQuery( + "select xmlagg(xmlelement(name a, e.theString) order by e.id) FILTER (WHERE foo = bar) OVER (PARTITION BY expression) from Entity e"); + } + + @Test // GH-3883 + void xmlTable() { + + assertQuery(""" + select + t.nonExistingWithDefault + from xmltable('/root/elem' passing :xml columns theInt Integer, + theFloat Float, + theString String, + theBoolean Boolean, + theNull String, + theObject XML, + theNestedString String path 'theObject/nested', + nonExisting String, + nonExistingWithDefault String default 'none') t + """); + } + } 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 c476f0d5bc..d1c5adfa48 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -17,24 +17,33 @@ import static org.assertj.core.api.Assertions.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; 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; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.PageRequest; 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; /** - * Verify that HQL queries are properly transformed through the {@link JpaQueryEnhancer} and the {@link HqlQueryParser}. + * Verify that HQL queries are properly transformed through the {@link JpaQueryEnhancer} and the + * {@link JpaQueryEnhancer.HqlQueryParser}. * * @author Greg Turnquist * @author Christoph Strobl + * @author Mark Paluch */ class HqlQueryTransformerTests { @@ -72,6 +81,21 @@ void applyingSortShouldCreateAdditionalOrderByCriteria() { assertThat(results).contains("ORDER BY e.role, e.hire_date, e.first_name asc, e.last_name asc"); } + @Test // GH-1280 + void nullFirstLastSorting() { + + // given + var original = "SELECT e FROM Employee e where e.name = :name ORDER BY e.first_name asc NULLS FIRST"; + + assertThat(createQueryFor(original, Sort.unsorted())).isEqualTo(original); + + assertThat(createQueryFor(original, Sort.by(Order.desc("lastName").nullsLast()))).startsWith(original) + .endsWithIgnoringCase("e.lastName DESC NULLS LAST"); + + assertThat(createQueryFor(original, Sort.by(Order.desc("lastName").nullsFirst()))).startsWith(original) + .endsWithIgnoringCase("e.lastName DESC NULLS FIRST"); + } + @Test void applyCountToSimpleQuery() { @@ -85,6 +109,36 @@ void applyCountToSimpleQuery() { assertThat(results).isEqualTo("select count(e) FROM Employee e where e.name = :name"); } + @Test // GH-3902 + void applyCountToFromQueryWithoutIdentificationVariable() { + + // given + var original = "FROM Employee where name = :name"; + + // when + var results = createCountQueryFor(original); + + // then + assertThat(results).isEqualTo("select count(__) FROM Employee AS __ where name = :name"); + } + + @Test // GH-3536 + void shouldCreateCountQueryForDistinctCount() { + + // given + var original = """ + select distinct cast(e.timestampField as date) as foo + from ExampleEntity e + order by cast(e.timestampField as date) desc + """; + + // when + var results = createCountQueryFor(original); + + // then + assertThat(results).isEqualTo("select count(distinct cast(e.timestampField as date)) from ExampleEntity e"); + } + @Test void applyCountToMoreComplexQuery() { @@ -98,6 +152,12 @@ void applyCountToMoreComplexQuery() { assertThat(results).isEqualTo("SELECT count(e) FROM Employee e where e.name = :name"); } + @Test // GH-3902 + void usesAsteriskAliasOfMultiselectForCountQuery() { + assertCountQuery("SELECT e.foo, e.bar FROM Employee e where e.name = :name ORDER BY e.modified_date", + "SELECT count(e) FROM Employee e where e.name = :name"); + } + @Test void applyCountToAlreadySortedQuery() { @@ -111,6 +171,24 @@ void applyCountToAlreadySortedQuery() { assertThat(results).isEqualTo("SELECT count(e) FROM Employee e where e.name = :name"); } + @Test // GH-3726 + void shouldCreateCountQueryForCTE() { + + // given + var original = """ + WITH cte_select AS (select u.firstname as firstname, u.lastname as lastname from User u) + SELECT new org.springframework.data.jpa.repository.sample.UserExcerptDto(c.firstname, c.lastname) + FROM cte_select c + """; + + // when + var results = createCountQueryFor(original); + + // then + assertThat(results).isEqualToIgnoringWhitespace( + "WITH cte_select AS (select u.firstname as firstname, u.lastname as lastname from User u) SELECT count(*) FROM cte_select c"); + } + @Test void multipleAliasesShouldBeGathered() { @@ -126,6 +204,12 @@ void multipleAliasesShouldBeGathered() { @Test void createsCountQueryCorrectly() { + + assertCountQuery("SELECT id FROM Person", "SELECT count(*) FROM Person"); + assertCountQuery("SELECT p.id FROM Person p", "SELECT count(p) FROM Person p"); + assertCountQuery("SELECT id FROM Person p", "SELECT count(p) FROM Person p"); + assertCountQuery("SELECT id, name FROM Person", "SELECT count(*) FROM Person"); + assertCountQuery("SELECT id, name FROM Person p", "SELECT count(p) FROM Person p"); assertCountQuery(QUERY, COUNT_QUERY); } @@ -148,6 +232,9 @@ void createsCountQueryForConstructorQueries() { assertCountQuery("select distinct new com.example.User(u.name) from User u where u.foo = ?1", "select count(distinct u) from User u where u.foo = ?1"); + + assertCountQuery("select distinct new com.example.User(name, lastname) from User where foo = ?1", + "select count(distinct name, lastname) from User where foo = ?1"); } @Test @@ -164,7 +251,15 @@ void createsCountQueryForQueriesWithSubSelectsSelectQuery() { "select count(u) from User u left outer join u.roles r where r in (select r from Role r)"); } - @Test + @Test // GH-3902 + void createsCountQueryForQueriesWithoutVariableWithSubSelectsSelectQuery() { + + assertCountQuery( + "select u, (select foo from bar b) from User left outer join u.roles r where r in (select r from Role r)", + "select count(*) from User left outer join u.roles r where r in (select r from Role r)"); + } + + @Test // GH-3902 void createsCountQueryForQueriesWithSubSelects() { assertCountQuery("from User u left outer join u.roles r where r in (select r from Role r) select u ", @@ -181,7 +276,7 @@ void allowsShortJpaSyntax() { assertCountQuery(SIMPLE_QUERY, COUNT_QUERY); } - @Test // GH-2260 + @Test // GH-2260, GH-3902 void detectsAliasCorrectly() { assertThat(alias(QUERY)).isEqualTo("u"); @@ -197,10 +292,15 @@ void detectsAliasCorrectly() { assertThat(alias("select u from User u where not exists (select u2 from User u2)")).isEqualTo("u"); assertThat(alias( "select u from User u where not exists (select u2 from User u2 where not exists (select u3 from User u3))")) - .isEqualTo("u"); + .isEqualTo("u"); assertThat(alias( "SELECT e FROM DbEvent e WHERE TREAT(modifiedFrom AS date) IS NULL OR e.modificationDate >= :modifiedFrom")) - .isEqualTo("e"); + .isEqualTo("e"); + assertThat(alias("select u, (select u2 from User u2) from User u")).isEqualTo("u"); + assertThat(alias("select firstname from User JOIN (select u2 from User u2) u2")).isNull(); + assertThat(alias("select firstname from User UNION select lastname from User b")).isNull(); + assertThat(alias("select firstname from User UNION select lastname from User UNION select lastname from User b")) + .isNull(); } @Test // GH-2557 @@ -208,29 +308,21 @@ 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(""" - select u - from user u - where exists (select u2 - from user u2 - ) - order by u.age desc"""); + """).rewrite(new DefaultQueryRewriteInformation(sort, + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())))) + .isEqualToIgnoringWhitespace(""" + select u + from user u + where exists (select u2 + from user u2 + ) + order by u.age desc"""); } @Test // GH-2563 @@ -364,14 +456,13 @@ void detectsConstructorExpressionInDistinctQuery() { @Test // DATAJPA-938 void detectsComplexConstructorExpression() { - // assertThat(hasConstructorExpression( """ 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 group by ip.id, ip.name, lp.accountId order by ip.name ASC""")) - .isTrue(); + .isTrue(); } @Test // DATAJPA-938 @@ -463,7 +554,7 @@ void doesNotPrefixAliasedFunctionCallNameWithDots() { String query = "SELECT AVG(m.price) AS m.avg FROM Magazine m"; Sort sort = Sort.by("m.avg"); - assertThatIllegalArgumentException().isThrownBy(() -> createQueryFor(query, sort)); + assertThatExceptionOfType(BadJpqlGrammarException.class).isThrownBy(() -> createQueryFor(query, sort)); } @Test // DATAJPA-965, DATAJPA-970, GH-2863 @@ -478,8 +569,10 @@ void doesNotPrefixAliasedFunctionCallNameWhenQueryStringContainsMultipleWhiteSpa @Test // DATAJPA-1506 void detectsAliasWithGroupAndOrderBy() { - assertThat(alias("select * from User group by name")).isNull(); - assertThat(alias("select * from User order by name")).isNull(); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User group by name")); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User order by name")); assertThat(alias("select u from User u group by name")).isEqualTo("u"); assertThat(alias("select u from User u order by name")).isEqualTo("u"); } @@ -487,9 +580,6 @@ void detectsAliasWithGroupAndOrderBy() { @Test // DATAJPA-1500 void createCountQuerySupportsWhitespaceCharacters() { - // - // - // assertThat(createCountQueryFor(""" select user from User user where user.age = 18 @@ -500,14 +590,39 @@ select count(user) from User user """); } + @Test // GH-3504 + void createCountWithCteShouldWork() { + + String countQuery = createCountQueryFor(""" + WITH maxId AS(select max(sr.snapshot.id) snapshotId from SnapshotReference sr + where sr.id.selectionId = ?1 and sr.enabled + group by sr.userId) + select sr from maxId m join SnapshotReference sr on sr.snapshot.id = m.snapshotId + """); + + assertThat(countQuery).startsWith("WITH maxId AS (select max(sr.snapshot.id) snapshotId from SnapshotReference sr") + .endsWith("select count(*) from maxId m join SnapshotReference sr on sr.snapshot.id = m.snapshotId"); + } + + @Test // GH-3504 + void createSortedQueryWithCteShouldWork() { + + String sortedQuery = createQueryFor(""" + WITH maxId AS(select max(sr.snapshot.id) snapshotId from SnapshotReference sr + where sr.id.selectionId = ?1 and sr.enabled + group by sr.userId) + select sr from maxId m join SnapshotReference sr on sr.snapshot.id = m.snapshotId + """, Sort.by("sr.snapshot")); + + assertThat(sortedQuery).startsWith( + "WITH maxId AS (select max(sr.snapshot.id) snapshotId from SnapshotReference sr where sr.id.selectionId = ?1 and sr.enabled group by sr.userId)") + .endsWith( + "select sr from maxId m join SnapshotReference sr on sr.snapshot.id = m.snapshotId order by sr.snapshot asc"); + } + @Test void createCountQuerySupportsLineBreaksInSelectClause() { - // - // - // - // - // assertThat(createCountQueryFor(""" select user.age, user.name @@ -570,10 +685,6 @@ void appliesSortCorrectlyForSimpleField() { @Test void createCountQuerySupportsLineBreakRightAfterDistinct() { - // - // - // - // assertThat(createCountQueryFor(""" select distinct @@ -593,8 +704,10 @@ void createCountQuerySupportsLineBreakRightAfterDistinct() { @Test void detectsAliasWithGroupAndOrderByWithLineBreaks() { - assertThat(alias("select * from User group\nby name")).isNull(); - assertThat(alias("select * from User order\nby name")).isNull(); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User group\nby name")); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User order\nby name")); assertThat(alias("select u from User u group\nby name")).isEqualTo("u"); assertThat(alias("select u from User u order\nby name")).isEqualTo("u"); assertThat(alias("select u from User\nu\norder \n by name")).isEqualTo("u"); @@ -617,7 +730,8 @@ void findProjectionClauseWithSubselect() { // This is not a required behavior, in fact the opposite is, // but it documents a current limitation. // to fix this without breaking findProjectionClauseWithIncludedFrom we need a more sophisticated parser. - assertThat(projection("select * from (select x from y)")).isNotEqualTo("*"); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> projection("select * from (select x from y)")); } @Test // DATAJPA-1696 @@ -688,15 +802,16 @@ void applySortingAccountsForNativeWindowFunction() { .isEqualTo("select dense_rank() over (order by lastname) from user u order by u.lastname, u.age desc"); // partition by + order by in over clause - assertThat(createQueryFor("select dense_rank() over (partition by active, age order by lastname) from user u", + assertThat(createQueryFor( + "select dense_rank() over (partition by active, age order by lastname range between 1.0 preceding and 1.0 following) from user u", sort)).isEqualTo( - "select dense_rank() over (partition by active, age order by lastname) from user u order by u.age desc"); + "select dense_rank() over (partition by active, age order by lastname range between 1.0 preceding and 1.0 following) from user u order by u.age desc"); // partition by + order by in over clause + order by at the end assertThat(createQueryFor( "select dense_rank() over (partition by active, age order by lastname) from user u order by active", sort)) - .isEqualTo( - "select dense_rank() over (partition by active, age order by lastname) from user u order by active, u.age desc"); + .isEqualTo( + "select dense_rank() over (partition by active, age order by lastname) from user u order by active, u.age desc"); // partition by + order by in over clause + frame clause assertThat(createQueryFor( @@ -721,13 +836,13 @@ void applySortingAccountsForNativeWindowFunction() { // order by in subselect (from expression) assertThat(createQueryFor("select u from (select u2 from user u2 order by age desc limit 10) u", sort)) - .isEqualTo("select u from (select u2 from user u2 order by age desc limit 10 ) u order by u.age desc"); + .isEqualTo("select u from (select u2 from user u2 order by age desc limit 10) u order by u.age desc"); // order by in subselect (from expression) + at the end assertThat(createQueryFor( "select u from (select u2 from user u2 order by 1, 2, 3 desc limit 10) u order by u.active asc", sort)) - .isEqualTo( - "select u from (select u2 from user u2 order by 1, 2, 3 desc limit 10 ) u order by u.active asc, u.age desc"); + .isEqualTo( + "select u from (select u2 from user u2 order by 1, 2, 3 desc limit 10) u order by u.active asc, u.age desc"); } @Test // GH-2511 @@ -738,7 +853,7 @@ void countQueryUsesCorrectVariable() { assertThat( createCountQueryFor("SELECT e FROM mytable e WHERE nr = :number AND kon = :kon AND datum >= '2019-01-01'")) - .isEqualTo("SELECT count(e) FROM mytable e WHERE nr = :number AND kon = :kon AND datum >= '2019-01-01'"); + .isEqualTo("SELECT count(e) FROM mytable e WHERE nr = :number AND kon = :kon AND datum >= '2019-01-01'"); assertThat(createCountQueryFor("SELECT e FROM context e ORDER BY time")) .isEqualTo("SELECT count(e) FROM context e"); @@ -748,7 +863,39 @@ void countQueryUsesCorrectVariable() { assertThat( createCountQueryFor("SELECT us FROM users_statuses us WHERE (user_created_at BETWEEN :fromDate AND :toDate)")) - .isEqualTo("SELECT count(us) FROM users_statuses us WHERE (user_created_at BETWEEN :fromDate AND :toDate)"); + .isEqualTo("SELECT count(us) FROM users_statuses us WHERE (user_created_at BETWEEN :fromDate AND :toDate)"); + } + + @Test // GH-3269, GH-3689 + void createsCountQueryUsingAliasCorrectly() { + + assertCountQuery("select distinct 1 as x from Employee", "select count(distinct 1) from Employee"); + assertCountQuery("SELECT DISTINCT abc AS x FROM T", "SELECT count(DISTINCT abc) FROM T"); + assertCountQuery("select distinct a as x, b as y from Employee", "select count(distinct a, b) from Employee"); + assertCountQuery("select distinct sum(amount) as x from Employee GROUP BY n", + "select count(distinct sum(amount)) from Employee GROUP BY n"); + assertCountQuery("select distinct a, b, sum(amount) as c, d from Employee GROUP BY n", + "select count(distinct a, b, sum(amount), d) from Employee GROUP BY n"); + assertCountQuery("select distinct a, count(b) as c from Employee GROUP BY n", + "select count(distinct a, count(b)) from Employee GROUP BY n"); + assertCountQuery("select distinct substring(e.firstname, 1, position('a' in e.lastname)) as x from from Employee", + "select count(distinct substring(e.firstname, 1, position('a' in e.lastname))) from from Employee"); + } + + @Test // GH-3902 + void createsCountQueryWithoutAlias() { + + assertCountQuery( + "SELECT this.quantity FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'", + "SELECT count(*) FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'"); + } + + @Test // GH-3902 + void createsCountQueryFromMultiselectWithoutAlias() { + + assertCountQuery( + "SELECT this.quantity, that.quantity FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'", + "SELECT count(*) FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'"); } @Test // GH-2496, GH-2522, GH-2537, GH-2045 @@ -840,7 +987,7 @@ void queryParserPicksCorrectAliasAmidstMultipleAlises() { assertThat(alias("select u from User as u left join u.roles as r")).isEqualTo("u"); } - @Test // GH-2032 + @Test // GH-2032, GH-3792 void countQueryShouldWorkEvenWithoutExplicitAlias() { assertCountQuery("FROM BookError WHERE portal = :portal", @@ -854,7 +1001,7 @@ void countQueryShouldWorkEvenWithoutExplicitAlias() { @MethodSource("queriesWithReservedWordsAsIdentifiers") // GH-2864 void usingReservedWordAsRelationshipNameShouldWork(String relationshipName, String joinAlias) { - HqlQueryParser.parseQuery(String.format(""" + JpaQueryEnhancer.HqlQueryParser.parseQuery(String.format(""" select u from UserAccountEntity u join fetch u.lossInspectorLimitConfiguration lil @@ -871,7 +1018,7 @@ where exists ( and iu = u ) and ct.id = :teamId - """, relationshipName, joinAlias, joinAlias)); + """, relationshipName, joinAlias, joinAlias)); } static Stream queriesWithReservedWordsAsIdentifiers() { @@ -880,7 +1027,6 @@ static Stream queriesWithReservedWordsAsIdentifiers() { Arguments.of("right", "rt"), // Arguments.of("left", "lt"), // Arguments.of("outer", "ou"), // - Arguments.of("full", "full"), // Arguments.of("inner", "inr")); } @@ -946,7 +1092,7 @@ void sortShouldWorkWhenAliasingFunctions() { """, Sort.by(Sort.Direction.ASC, "cheapestBundlePrice") // .and(Sort.by(Sort.Direction.ASC, "earliestBundleStart")) // .and(Sort.by(Sort.Direction.ASC, "name")))) - .endsWith(" order by cheapestBundlePrice asc, earliestBundleStart asc, name asc"); + .endsWith(" order by cheapestBundlePrice asc, earliestBundleStart asc, name asc"); } @Test // GH-2863, GH-1655 @@ -974,16 +1120,21 @@ void fromWithoutAPrimaryAliasShouldWork() { .isEqualTo("FROM Story WHERE enabled = true order by created desc"); } - @Test // GH-2977 - void isSubqueryThrowsException() { - - String query = """ - insert into MyEntity (id, col) - select max(id), col - from MyEntityStaging - group by col - """; - + @ParameterizedTest + @ValueSource(strings = { """ + insert into MyEntity (id, col) + select max(id), col + from MyEntityStaging + group by col + """, """ + update MyEntity AS mes + set mes.col = 'test' + where mes.id = 1 + """, """ + delete MyEntity AS mes + where mes.col = 'test' + """ }) // GH-2977, GH-3649 + void isSubqueryThrowsException(String query) { assertThat(createQueryFor(query, Sort.unsorted())).isEqualToIgnoringWhitespace(query); } @@ -1012,8 +1163,7 @@ void aliasesShouldNotOverlapWithSortProperties() { assertThat( createQueryFor("select e from Employee e where e.name = :name", Sort.by(Sort.Order.desc("evaluationDate")))) - .isEqualToIgnoringWhitespace( - "select e from Employee e where e.name = :name order by e.evaluationDate desc"); + .isEqualToIgnoringWhitespace("select e from Employee e where e.name = :name order by e.evaluationDate desc"); assertThat(createQueryFor("select e from Employee e join training t where e.name = :name", Sort.by(Sort.Order.desc("trainingDueDate")))).isEqualToIgnoringWhitespace( @@ -1032,15 +1182,67 @@ void aliasesShouldNotOverlapWithSortProperties() { "SELECT t3 FROM Test3 t3 JOIN t3.test2 x WHERE x.id = :test2Id order by t3.testDuplicateColumnName desc"); } - @Test // GH-3269 - void createsCountQueryUsingAliasCorrectly() { + @Test // GH-3864 + void testCountFromFunctionWithAlias() { - assertCountQuery("select distinct 1 as x from Employee","select count(distinct 1) from Employee AS __"); - assertCountQuery("SELECT DISTINCT abc AS x FROM T","SELECT count(DISTINCT abc) FROM T AS __"); - assertCountQuery("select distinct a as x, b as y from Employee","select count(distinct a , b) from Employee AS __"); - assertCountQuery("select distinct sum(amount) as x from Employee GROUP BY n","select count(distinct sum(amount)) from Employee AS __ GROUP BY n"); - assertCountQuery("select distinct a, b, sum(amount) as c, d from Employee GROUP BY n","select count(distinct a, b, sum(amount) , d) from Employee AS __ GROUP BY n"); - assertCountQuery("select distinct a, count(b) as c from Employee GROUP BY n","select count(distinct a, count(b)) from Employee AS __ GROUP BY n"); + // given + var original = "select x.id, x.value from some_function(:date, :integerValue) x"; + + // when + var results = createCountQueryFor(original); + + // then + assertThat(results).contains("select count(*) from some_function(:date, :integerValue) x"); + } + + @Test // GH-3864 + void testCountFromMultiselectFunctionNoAlias() { + + // given + var original = "select id, value from some_function(:date, :integerValue)"; + + // when + var results = createCountQueryFor(original); + + // then + assertThat(results).contains("select count(*) from some_function(:date, :integerValue)"); + } + + @Test // GH-3427 + void sortShouldBeAppendedWithSpacingInCaseOfSetOperator() { + + String source = "SELECT tb FROM Test tb WHERE (tb.type='A') UNION SELECT tb FROM Test tb WHERE (tb.type='B') UNION SELECT tb FROM Test tb WHERE (tb.type='C')"; + 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') " // + + "UNION SELECT tb FROM Test tb WHERE (tb.type = 'C') order by tb.Type asc"); + } + + @ParameterizedTest // GH-3427 + @ValueSource(strings = { "", "res" }) + void sortShouldBeAppendedToSubSelectWithSetOperatorInSubselect(String alias) { + + String prefix = StringUtils.hasText(alias) ? (alias + ".") : ""; + String source = "SELECT %sname FROM (SELECT c.name as name FROM Category c UNION SELECT t.name as name FROM Tag t)" + .formatted(prefix); + + if (StringUtils.hasText(alias)) { + source = source + " %s".formatted(alias); + } + + String target = createQueryFor(source, Sort.by("name").ascending()); + + assertThat(target).contains(" UNION SELECT ").doesNotContainPattern(Pattern.compile(".*\\SUNION")); + assertThat(target).endsWith("order by %sname asc".formatted(prefix)).satisfies(it -> { + Pattern pattern = Pattern.compile("order by"); + Matcher matcher = pattern.matcher(target); + int count = 0; + while (matcher.find()) { + count++; + } + assertThat(count).describedAs("Found order by clause more than once in: \n%s", it).isOne(); + }); } private void assertCountQuery(String originalQuery, String countQuery) { @@ -1048,7 +1250,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) { @@ -1059,8 +1262,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(); } @@ -1073,6 +1275,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 3440616f4c..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlSpecificationTests.java +++ /dev/null @@ -1,1401 +0,0 @@ -/* - * Copyright 2022-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.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -/** - * 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
      - *
      - * IMPORTANT: Purely verifies the parser without any transformations. - * - * @author Greg Turnquist - * @since 3.1 - */ -class HqlSpecificationTests { - - private static final String SPEC_FAULT = "Disabled due to spec fault> "; - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example - */ - @Test - void joinExample1() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE e.contactInfo.address.zipcode = '95054' - """); - } - - @Test - void pathExpressionSyntaxExample1() { - - HqlQueryParser.parseQuery(""" - SELECT DISTINCT l.product - FROM Order AS o JOIN o.lineItems l - """); - } - - @Test - void joinsExample1() { - - HqlQueryParser.parseQuery(""" - SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize - """); - } - - @Test - void joinsExample2() { - - HqlQueryParser.parseQuery(""" - SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInnerExample() { - - HqlQueryParser.parseQuery(""" - SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInExample() { - - HqlQueryParser.parseQuery(""" - SELECT OBJECT(c) FROM Customer c, IN(c.orders) o WHERE c.status = 1 - """); - } - - @Test - void doubleJoinExample() { - - HqlQueryParser.parseQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE c.address.zipcode = '95054' - """); - } - - @Test - void leftJoinExample() { - - HqlQueryParser.parseQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - GROUP BY s.name - """); - } - - @Test - void leftJoinOnExample() { - - HqlQueryParser.parseQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - ON p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinWhereExample() { - - HqlQueryParser.parseQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - WHERE p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinFetchExample() { - - HqlQueryParser.parseQuery(""" - SELECT d - FROM Department d LEFT JOIN FETCH d.employees - WHERE d.deptno = 1 - """); - } - - @Test - void collectionMemberExample() { - - HqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void collectionMemberInExample() { - - HqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o, IN(o.lineItems) l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void fromClauseExample() { - - HqlQueryParser.parseQuery(""" - SELECT o - FROM Order AS o JOIN o.lineItems l JOIN l.product p - """); - } - - @Test - void fromClauseDowncastingExample1() { - - HqlQueryParser.parseQuery(""" - SELECT b.name, b.ISBN - FROM Order o JOIN TREAT(o.product AS Book) b - """); - } - - @Test - void fromClauseDowncastingExample2() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT e FROM Employee e - WHERE TREAT(e AS Exempt).vacationDays > 10 - OR TREAT(e AS Contractor).hours > 100 - """); - } - - @Test - void pathExpressionsNamedParametersExample() { - - HqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE c.status = :stat - """); - } - - @Test - void betweenExpressionsExample() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void memberOfExample() { - - HqlQueryParser.parseQuery(""" - SELECT p - FROM Person p - WHERE 'Joe' MEMBER OF p.nicknames - """); - } - - @Test - void existsSubSelectExample1() { - - HqlQueryParser.parseQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS ( - SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void allExample() { - - HqlQueryParser.parseQuery(""" - SELECT emp - FROM Employee emp - WHERE emp.salary > ALL ( - SELECT m.salary - FROM Manager m - WHERE m.department = emp.department) - """); - } - - @Test - void existsSubSelectExample2() { - - HqlQueryParser.parseQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS ( - SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void subselectNumericComparisonExample1() { - - HqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE (SELECT AVG(o.price) FROM c.orders o) > 100 - """); - } - - @Test - void subselectNumericComparisonExample2() { - - HqlQueryParser.parseQuery(""" - SELECT goodCustomer - FROM Customer goodCustomer - WHERE goodCustomer.balanceOwed < ( - SELECT AVG(c.balanceOwed)/2.0 FROM Customer c) - """); - } - - @Test - void indexExample() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) - """); - } - - @Test - void functionInvocationExampleWithCorrection() { - - HqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE - """); - } - - @Test - void updateCaseExample1() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (Exempt, Contractor) - """); - } - - @Test - void theRest2() { - - HqlQueryParser.parseQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (:empType1, :empType2) - """); - } - - @Test - void theRest3() { - - HqlQueryParser.parseQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN :empTypes - """); - } - - @Test - void theRest4() { - - HqlQueryParser.parseQuery(""" - SELECT TYPE(e) - FROM Employee e - WHERE TYPE(e) <> Exempt - """); - } - - @Test - void theRest5() { - - HqlQueryParser.parseQuery(""" - SELECT c.status, AVG(c.filledOrderCount), COUNT(c) - FROM Customer c - GROUP BY c.status - HAVING c.status IN (1, 2) - """); - } - - @Test - void theRest6() { - - HqlQueryParser.parseQuery(""" - SELECT c.country, COUNT(c) - FROM Customer c - GROUP BY c.country - HAVING COUNT(c) > 30 - """); - } - - @Test - void theRest7() { - - HqlQueryParser.parseQuery(""" - SELECT c, COUNT(o) - FROM Customer c JOIN c.orders o - GROUP BY c - HAVING COUNT(o) >= 5 - """); - } - - @Test - void theRest8() { - - HqlQueryParser.parseQuery(""" - SELECT c.id, c.status - FROM Customer c JOIN c.orders o - WHERE o.count > 100 - """); - } - - @Test - void theRest9() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT o.lineItems FROM Order AS o - """); - } - - @Test - void theRest11() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT e.address AS addr - FROM Employee e - """); - } - - @Test - void theRest14() { - - HqlQueryParser.parseQuery(""" - SELECT AVG(o.quantity) FROM Order o - """); - } - - @Test - void theRest15() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT COUNT(o) FROM Order o - """); - } - - @Test - void theRest17() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - """); - } - - @Test - void theRest26() { - - HqlQueryParser.parseQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - AND c.orders IS EMPTY - """); - } - - @Test - void theRest27() { - - HqlQueryParser.parseQuery(""" - UPDATE Customer c - SET c.status = 'outstanding' - WHERE c.balance < 10000 - """); - } - - @Test - void theRest28() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - """); - } - - @Test - void theRest30() { - - HqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress.state = 'CA' - """); - } - - @Test - void theRest31() { - - HqlQueryParser.parseQuery(""" - SELECT DISTINCT o.shippingAddress.state - FROM Order o - """); - } - - @Test - void theRest32() { - - HqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - """); - } - - @Test - void theRest33() { - - HqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS NOT EMPTY - """); - } - - @Test - void theRest34() { - - HqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void theRest35() { - - HqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.shipped = FALSE - """); - } - - @Test - void theRest36() { - - HqlQueryParser.parseQuery(""" - 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() { - - HqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress <> o.billingAddress - """); - } - - @Test - void theRest38() { - - HqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.name = ?1 - """); - } - - @Test - void hqlQueries() { - - HqlQueryParser.parseQuery("from Person"); - HqlQueryParser.parseQuery("select local datetime"); - HqlQueryParser.parseQuery("from Person p select p.name"); - HqlQueryParser.parseQuery("update Person set nickName = 'Nacho' " + // - "where name = 'Ignacio'"); - HqlQueryParser.parseQuery("update Person p " + // - "set p.name = :newName " + // - "where p.name = :oldName"); - HqlQueryParser.parseQuery("update Person " + // - "set name = :newName " + // - "where name = :oldName"); - HqlQueryParser.parseQuery("update versioned Person " + // - "set name = :newName " + // - "where name = :oldName"); - HqlQueryParser.parseQuery("insert Person (id, name) " + // - "values (100L, 'Jane Doe')"); - HqlQueryParser.parseQuery("insert Person (id, name) " + // - "values (101L, 'J A Doe III'), " + // - "(102L, 'J X Doe'), " + // - "(103L, 'John Doe, Jr')"); - HqlQueryParser.parseQuery("insert into Partner (id, name) " + // - "select p.id, p.name " + // - "from Person p "); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.name like 'Joe'"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.name like 'Joe''s'"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.id = 1"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.id = 1L"); - HqlQueryParser.parseQuery("select c " + // - "from Call c " + // - "where c.duration > 100.5"); - HqlQueryParser.parseQuery("select c " + // - "from Call c " + // - "where c.duration > 100.5F"); - HqlQueryParser.parseQuery("select c " + // - "from Call c " + // - "where c.duration > 1e+2"); - HqlQueryParser.parseQuery("select c " + // - "from Call c " + // - "where c.duration > 1e+2F"); - HqlQueryParser.parseQuery("from Phone ph " + // - "where ph.type = LAND_LINE"); - HqlQueryParser.parseQuery("select java.lang.Math.PI"); - HqlQueryParser.parseQuery("select 'Customer ' || p.name " + // - "from Person p " + // - "where p.id = 1"); - HqlQueryParser.parseQuery("select sum(ch.duration) * :multiplier " + // - "from Person pr " + // - "join pr.phones ph " + // - "join ph.callHistory ch " + // - "where ph.id = 1L "); - HqlQueryParser.parseQuery("select year(local date) - year(p.createdOn) " + // - "from Person p " + // - "where p.id = 1L"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where year(local date) - year(p.createdOn) > 1"); - HqlQueryParser.parseQuery("select " + // - " case p.nickName " + // - " when 'NA' " + // - " then '' " + // - " else p.nickName " + // - " end " + // - "from Person p"); - HqlQueryParser.parseQuery("select " + // - " case " + // - " when p.nickName is null " + // - " then " + // - " case " + // - " when p.name is null " + // - " then '' " + // - " else p.name " + // - " end" + // - " else p.nickName " + // - " end " + // - "from Person p"); - HqlQueryParser.parseQuery("select " + // - " case when p.nickName is null " + // - " then p.id * 1000 " + // - " else p.id " + // - " end " + // - "from Person p " + // - "order by p.id"); - HqlQueryParser.parseQuery("select p " + // - "from Payment p " + // - "where type(p) = CreditCardPayment"); - HqlQueryParser.parseQuery("select p " + // - "from Payment p " + // - "where type(p) = :type"); - HqlQueryParser.parseQuery("select p " + // - "from Payment p " + // - "where length(treat(p as CreditCardPayment).cardNumber) between 16 and 20"); - HqlQueryParser.parseQuery("select nullif(p.nickName, p.name) " + // - "from Person p"); - HqlQueryParser.parseQuery("select " + // - " case" + // - " when p.nickName = p.name" + // - " then null" + // - " else p.nickName" + // - " end " + // - "from Person p"); - HqlQueryParser.parseQuery("select coalesce(p.nickName, '') " + // - "from Person p"); - HqlQueryParser.parseQuery("select coalesce(p.nickName, p.name, '') " + // - "from Person p"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where size(p.phones) >= 2"); - HqlQueryParser.parseQuery("select concat(p.number, ' : ' , cast(c.duration as string)) " + // - "from Call c " + // - "join c.phone p"); - HqlQueryParser.parseQuery("select substring(p.number, 1, 2) " + // - "from Call c " + // - "join c.phone p"); - HqlQueryParser.parseQuery("select upper(p.name) " + // - "from Person p "); - HqlQueryParser.parseQuery("select lower(p.name) " + // - "from Person p "); - HqlQueryParser.parseQuery("select trim(p.name) " + // - "from Person p "); - HqlQueryParser.parseQuery("select trim(leading ' ' from p.name) " + // - "from Person p "); - HqlQueryParser.parseQuery("select length(p.name) " + // - "from Person p "); - HqlQueryParser.parseQuery("select locate('John', p.name) " + // - "from Person p "); - HqlQueryParser.parseQuery("select abs(c.duration) " + // - "from Call c "); - HqlQueryParser.parseQuery("select mod(c.duration, 10) " + // - "from Call c "); - HqlQueryParser.parseQuery("select sqrt(c.duration) " + // - "from Call c "); - HqlQueryParser.parseQuery("select cast(c.duration as String) " + // - "from Call c "); - HqlQueryParser.parseQuery("select str(c.timestamp) " + // - "from Call c "); - HqlQueryParser.parseQuery("select str(cast(duration as float) / 60, 4, 2) " + // - "from Call c "); - HqlQueryParser.parseQuery("select c " + // - "from Call c " + // - "where extract(date from c.timestamp) = local date"); - HqlQueryParser.parseQuery("select extract(year from c.timestamp) " + // - "from Call c "); - HqlQueryParser.parseQuery("select year(c.timestamp) " + // - "from Call c "); - HqlQueryParser.parseQuery("select var_samp(c.duration) as sampvar, var_pop(c.duration) as popvar " + // - "from Call c "); - HqlQueryParser.parseQuery("select bit_length(c.phone.number) " + // - "from Call c "); - HqlQueryParser.parseQuery("select c " + // - "from Call c " + // - "where c.duration < 30 "); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.name like 'John%' "); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.createdOn > '1950-01-01' "); - HqlQueryParser.parseQuery("select p " + // - "from Phone p " + // - "where p.type = 'MOBILE' "); - HqlQueryParser.parseQuery("select p " + // - "from Payment p " + // - "where p.completed = true "); - HqlQueryParser.parseQuery("select p " + // - "from Payment p " + // - "where type(p) = WireTransferPayment "); - HqlQueryParser.parseQuery("select p " + // - "from Payment p, Phone ph " + // - "where p.person = ph.person "); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "join p.phones ph " + // - "where p.id = 1L and index(ph) between 0 and 3"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.createdOn between '1999-01-01' and '2001-01-02'"); - HqlQueryParser.parseQuery("select c " + // - "from Call c " + // - "where c.duration between 5 and 20"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.name between 'H' and 'M'"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.nickName is not null"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.nickName is null"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.name like 'Jo%'"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.name not like 'Jo%'"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.name like 'Dr|_%' escape '|'"); - HqlQueryParser.parseQuery("select p " + // - "from Payment p " + // - "where type(p) in (CreditCardPayment, WireTransferPayment)"); - HqlQueryParser.parseQuery("select p " + // - "from Phone p " + // - "where type in ('MOBILE', 'LAND_LINE')"); - HqlQueryParser.parseQuery("select p " + // - "from Phone p " + // - "where type in :types"); - HqlQueryParser.parseQuery("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 " + // - ")"); - HqlQueryParser.parseQuery("select distinct p " + // - "from Phone p " + // - "where p.person in (" + // - " select py.person " + // - " from Payment py" + // - " where py.completed = true and py.amount > 50 " + // - ")"); - HqlQueryParser.parseQuery("select distinct p " + // - "from Payment p " + // - "where (p.amount, p.completed) in (" + // - " (50, true)," + // - " (100, true)," + // - " (5, false)" + // - ")"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where 1 in indices(p.phones)"); - HqlQueryParser.parseQuery("select distinct p.person " + // - "from Phone p " + // - "join p.calls c " + // - "where 50 > all (" + // - " select duration" + // - " from Call" + // - " where phone = p " + // - ") "); - HqlQueryParser.parseQuery("select p " + // - "from Phone p " + // - "where local date > all elements(p.repairTimestamps)"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where :phone = some elements(p.phones)"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where :phone member of p.phones"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where exists elements(p.phones)"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.phones is empty"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.phones is not empty"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.phones is not empty"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where 'Home address' member of p.addresses"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where 'Home address' not member of p.addresses"); - HqlQueryParser.parseQuery("select p " + // - "from Person p"); - HqlQueryParser.parseQuery("select p " + // - "from org.hibernate.userguide.model.Person p"); - HqlQueryParser.parseQuery("select distinct pr, ph " + // - "from Person pr, Phone ph " + // - "where ph.person = pr and ph is not null"); - HqlQueryParser.parseQuery("select distinct pr1 " + // - "from Person pr1, Person pr2 " + // - "where pr1.id <> pr2.id " + // - " and pr1.address = pr2.address " + // - " and pr1.createdOn < pr2.createdOn"); - HqlQueryParser.parseQuery("select distinct pr, ph " + // - "from Person pr cross join Phone ph " + // - "where ph.person = pr and ph is not null"); - HqlQueryParser.parseQuery("select p " + // - "from Payment p "); - HqlQueryParser.parseQuery("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"); - HqlQueryParser.parseQuery("select distinct pr " + // - "from Person pr " + // - "join Phone ph on ph.person = pr " + // - "where ph.type = :phoneType"); - HqlQueryParser.parseQuery("select distinct pr " + // - "from Person pr " + // - "join pr.phones ph " + // - "where ph.type = :phoneType"); - HqlQueryParser.parseQuery("select distinct pr " + // - "from Person pr " + // - "inner join pr.phones ph " + // - "where ph.type = :phoneType"); - HqlQueryParser.parseQuery("select distinct pr " + // - "from Person pr " + // - "left join pr.phones ph " + // - "where ph is null " + // - " or ph.type = :phoneType"); - HqlQueryParser.parseQuery("select distinct pr " + // - "from Person pr " + // - "left outer join pr.phones ph " + // - "where ph is null " + // - " or ph.type = :phoneType"); - HqlQueryParser.parseQuery("select pr.name, ph.number " + // - "from Person pr " + // - "left join pr.phones ph with ph.type = :phoneType "); - HqlQueryParser.parseQuery("select pr.name, ph.number " + // - "from Person pr " + // - "left join pr.phones ph on ph.type = :phoneType "); - HqlQueryParser.parseQuery("select distinct pr " + // - "from Person pr " + // - "left join fetch pr.phones "); - HqlQueryParser.parseQuery("select a, ccp " + // - "from Account a " + // - "join treat(a.payments as CreditCardPayment) ccp " + // - "where length(ccp.cardNumber) between 16 and 20"); - HqlQueryParser.parseQuery("select c, ccp " + // - "from Call c " + // - "join treat(c.payment as CreditCardPayment) ccp " + // - "where length(ccp.cardNumber) between 16 and 20"); - HqlQueryParser.parseQuery("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"); - HqlQueryParser.parseQuery("select ph " + // - "from Phone ph " + // - "where ph.person.address = :address "); - HqlQueryParser.parseQuery("select ph " + // - "from Phone ph " + // - "join ph.person pr " + // - "where pr.address = :address "); - HqlQueryParser.parseQuery("select ph " + // - "from Phone ph " + // - "where ph.person.address = :address " + // - " and ph.person.createdOn > :timestamp"); - HqlQueryParser.parseQuery("select ph " + // - "from Phone ph " + // - "inner join ph.person pr " + // - "where pr.address = :address " + // - " and pr.createdOn > :timestamp"); - HqlQueryParser.parseQuery("select ph " + // - "from Person pr " + // - "join pr.phones ph " + // - "join ph.calls c " + // - "where pr.address = :address " + // - " and c.duration > :duration"); - HqlQueryParser.parseQuery("select ch " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - HqlQueryParser.parseQuery("select value(ch) " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - HqlQueryParser.parseQuery("select key(ch) " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - HqlQueryParser.parseQuery("select key(ch) " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - HqlQueryParser.parseQuery("select entry(ch) " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - HqlQueryParser.parseQuery("select sum(ch.duration) " + // - "from Person pr " + // - "join pr.phones ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id " + // - " and index(ph) = :phoneIndex"); - HqlQueryParser.parseQuery("select value(ph.callHistory) " + // - "from Phone ph " + // - "where ph.id = :id "); - HqlQueryParser.parseQuery("select key(ph.callHistory) " + // - "from Phone ph " + // - "where ph.id = :id "); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.phones[0].type = LAND_LINE"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where p.addresses['HOME'] = :address"); - HqlQueryParser.parseQuery("select pr " + // - "from Person pr " + // - "where pr.phones[max(indices(pr.phones))].type = 'LAND_LINE'"); - HqlQueryParser.parseQuery("select p.name, p.nickName " + // - "from Person p "); - HqlQueryParser.parseQuery("select p.name as name, p.nickName as nickName " + // - "from Person p "); - HqlQueryParser.parseQuery("select new org.hibernate.userguide.hql.CallStatistics(" + // - " count(c), " + // - " sum(c.duration), " + // - " min(c.duration), " + // - " max(c.duration), " + // - " avg(c.duration)" + // - ") " + // - "from Call c "); - HqlQueryParser.parseQuery("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 "); - HqlQueryParser.parseQuery("select new list(" + // - " p.number, " + // - " c.duration " + // - ") " + // - "from Call c " + // - "join c.phone p "); - HqlQueryParser.parseQuery("select distinct p.lastName " + // - "from Person p"); - HqlQueryParser.parseQuery("select " + // - " count(c), " + // - " sum(c.duration), " + // - " min(c.duration), " + // - " max(c.duration), " + // - " avg(c.duration) " + // - "from Call c "); - HqlQueryParser.parseQuery("select count(distinct c.phone) " + // - "from Call c "); - HqlQueryParser.parseQuery("select p.number, count(c) " + // - "from Call c " + // - "join c.phone p " + // - "group by p.number"); - HqlQueryParser.parseQuery("select p " + // - "from Phone p " + // - "where max(elements(p.calls)) = :call"); - HqlQueryParser.parseQuery("select p " + // - "from Phone p " + // - "where min(elements(p.calls)) = :call"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "where max(indices(p.phones)) = 0"); - HqlQueryParser.parseQuery("select count(c) filter (where c.duration < 30) " + // - "from Call c "); - HqlQueryParser.parseQuery("select p.number, count(c) filter (where c.duration < 30) " + // - "from Call c " + // - "join c.phone p " + // - "group by p.number"); - HqlQueryParser.parseQuery("select listagg(p.number, ', ') within group (order by p.type,p.number) " + // - "from Phone p " + // - "group by p.person"); - HqlQueryParser.parseQuery("select sum(c.duration) " + // - "from Call c "); - HqlQueryParser.parseQuery("select p.name, sum(c.duration) " + // - "from Call c " + // - "join c.phone ph " + // - "join ph.person p " + // - "group by p.name"); - HqlQueryParser.parseQuery("select p, sum(c.duration) " + // - "from Call c " + // - "join c.phone ph " + // - "join ph.person p " + // - "group by p"); - HqlQueryParser.parseQuery("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"); - HqlQueryParser.parseQuery("select p.name from Person p " + // - "union " + // - "select p.nickName from Person p where p.nickName is not null"); - HqlQueryParser.parseQuery("select p " + // - "from Person p " + // - "order by p.name"); - HqlQueryParser.parseQuery("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"); - HqlQueryParser.parseQuery("select c " + // - "from Call c " + // - "join c.phone p " + // - "order by p.number " + // - "limit 50"); - HqlQueryParser.parseQuery("select c " + // - "from Call c " + // - "join c.phone p " + // - "order by p.number " + // - "fetch first 50 rows only"); - HqlQueryParser.parseQuery("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 58b5b65893..e5ae1b1da2 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -24,7 +24,10 @@ import org.junit.jupiter.params.ParameterizedTest; 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}. @@ -33,26 +36,60 @@ * @author Diego Krupitza * @author Geoffrey Deremetz * @author Christoph Strobl + * @author Soomin Kim */ -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); } - @Override - @ParameterizedTest // GH-2773 - @MethodSource("jpqlCountQueries") - void shouldDeriveJpqlCountQuery(String query, String expected) { + @Test // GH-3546 + void shouldApplySorting() { - assumeThat(query).as("JSQLParser does not support simple JPQL syntax").doesNotStartWithIgnoringCase("FROM"); + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.jpqlQuery("SELECT e FROM Employee e")); - assumeThat(query).as("JSQLParser does not support constructor JPQL syntax").doesNotContain(" new "); + String sql = enhancer.rewrite(new DefaultQueryRewriteInformation(Sort.by("foo", "bar"), + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()))); - assumeThat(query).as("JSQLParser does not support MOD JPQL syntax").doesNotContain("MOD("); + assertThat(sql).isEqualTo("SELECT e FROM Employee e ORDER BY e.foo ASC, e.bar ASC"); + } - super.shouldDeriveJpqlCountQuery(query, expected); + @Test // GH-3886 + void shouldApplySortingWithNullsPrecedence() { + + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.jpqlQuery("SELECT e FROM Employee e")); + + String sql = enhancer.rewrite(new DefaultQueryRewriteInformation( + Sort.by(Sort.Order.asc("foo").with(Sort.NullHandling.NULLS_LAST), + Sort.Order.desc("bar").with(Sort.NullHandling.NULLS_FIRST)), + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()))); + + assertThat(sql).isEqualTo("SELECT e FROM Employee e ORDER BY e.foo ASC NULLS LAST, e.bar DESC NULLS FIRST"); + } + + @Test // GH-3707 + void countQueriesShouldConsiderPrimaryTableAlias() { + + 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 + """)); + + String sql = enhancer.createCountQueryFor(null); + + assertThat(sql).startsWith("SELECT count(DISTINCT a.*) FROM TableA a"); + } + + @Override + @ParameterizedTest // GH-2773 + @MethodSource("jpqlCountQueries") + void shouldDeriveJpqlCountQuery(String query, String expected) { + assumeThat(query).as("JSQLParser does not support JPQL").isNull(); } @Test @@ -63,16 +100,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(); @@ -86,16 +123,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(); @@ -113,16 +150,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(); @@ -133,16 +170,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(); @@ -154,18 +190,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( - "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.createCountQueryFor(null).toLowerCase()).isEqualToIgnoringWhitespace( + "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.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(); @@ -177,18 +213,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( - "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.createCountQueryFor(null).toLowerCase()).isEqualToIgnoringWhitespace( + "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.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(); @@ -197,31 +233,44 @@ 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(query.getAlias()).isNull(); + assertThat(query.getProjection()).isEmpty(); + assertThat(query.hasConstructorExpression()).isFalse(); - assertThat(stringQuery.getAlias()).isNull(); - assertThat(stringQuery.getProjection()).isEmpty(); - assertThat(stringQuery.hasConstructorExpression()).isFalse(); + assertThatIllegalStateException() + .isThrownBy(() -> queryEnhancer.rewrite(getRewriteInformation(Sort.by("day").descending()))) + .isInstanceOf(IllegalStateException.class) + .withMessageContaining("Cannot apply sorting to OTHER statement"); - assertThat(queryEnhancer.applySorting(Sort.by("day").descending())).isEqualTo("TRUNCATE TABLE foo"); - assertThat(queryEnhancer.getJoinAliases()).isEmpty(); 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(); @@ -237,4 +286,48 @@ static Stream mergeStatementWorksSource() { "merge into a using (select id2, value from b) on (id = id2) when matched then update set a.value = value", null)); } + + @Test // GH-2856 + void nativeInsertQueryThrowsExceptionForCountQuery() { + + DeclaredQuery query = DeclaredQuery.nativeQuery("INSERT INTO users(name) VALUES('John')"); + QueryEnhancer enhancer = new JSqlParserQueryEnhancer(query); + + assertThatIllegalStateException().isThrownBy(() -> enhancer.createCountQueryFor(null)) + .withMessageContaining("Cannot derive count query for INSERT statement").withMessageContaining("SELECT"); + } + + @Test // GH-2856 + void nativeUpdateQueryThrowsExceptionForSorting() { + + DeclaredQuery query = DeclaredQuery.nativeQuery("UPDATE users SET name = 'test'"); + QueryEnhancer enhancer = new JSqlParserQueryEnhancer(query); + + // When/Then: Should throw IllegalStateException for sorting + Sort sort = Sort.by("id"); + QueryEnhancer.QueryRewriteInformation rewriteInfo = new DefaultQueryRewriteInformation( + sort, ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())); + + assertThatIllegalStateException().isThrownBy(() -> enhancer.rewrite(rewriteInfo)) + .withMessageContaining("Cannot apply sorting to UPDATE statement").withMessageContaining("SELECT"); + } + + @Test // GH-2856 + void nativeAllowsUnsortedForNonSelectQueries() { + + DeclaredQuery query = DeclaredQuery.nativeQuery("UPDATE users SET name = 'test'"); + QueryEnhancer enhancer = new JSqlParserQueryEnhancer(query); + + QueryEnhancer.QueryRewriteInformation rewriteInfo = new DefaultQueryRewriteInformation( + Sort.unsorted(), ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())); + + String result = enhancer.rewrite(rewriteInfo); + assertThat(result).containsIgnoringCase("UPDATE users"); + } + + 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 f09d50be0e..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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -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; @@ -50,6 +50,7 @@ * @author Mark Paluch * @author Jens Schauder * @author Krzysztof Krason + * @author Christoph Strobl */ @ExtendWith(SpringExtension.class) @ContextConfiguration("classpath:application-context.xml") @@ -166,6 +167,15 @@ void errorsOnUnknownProperties() { em.createEntityGraph(User.class))); } + @Test // GH-3682 + void allowsEmptyGraph() { + + EntityGraph graph = em.createEntityGraph(User.class); + Jpa21Utils.configureFetchGraphFrom(new JpaEntityGraph("User.NoNamedEntityGraphAvailable", EntityGraphType.FETCH, new String[0]), graph); + + Assertions.assertThat(graph.getAttributeNodes()).isEmpty(); + } + /** * Lookup the {@link AttributeNode} with given {@literal nodeName} in the root of the given {@literal graph}. */ @@ -181,8 +191,7 @@ void errorsOnUnknownProperties() { /** * Lookup the {@link AttributeNode} with given {@literal nodeName} in the {@link List} of given {@literal nodes}. */ - @Nullable - static AttributeNode findNode(String nodeName, List> nodes) { + static @Nullable AttributeNode findNode(String nodeName, List> nodes) { if (CollectionUtils.isEmpty(nodes)) { return null; @@ -201,8 +210,7 @@ static AttributeNode findNode(String nodeName, List> 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/Jpa21UtilsUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsUnitTests.java index 4885869be9..fab642d505 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. 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 3835426aba..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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -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 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 { 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..b6612bfb71 --- /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 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 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 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 { + + Window 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..22b8fcaa6c 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 @@ -51,6 +51,7 @@ void createsJpaParametersParameterAccessor() throws Exception { } @Test // GH-2370 + @SuppressWarnings({ "rawtypes", "unchecked" }) void createsHibernateParametersParameterAccessor() throws Exception { Method withNativeQuery = SampleRepository.class.getMethod("withNativeQuery", Integer.class); @@ -63,18 +64,17 @@ void createsHibernateParametersParameterAccessor() throws Exception { ArgumentCaptor> captor = ArgumentCaptor.forClass(TypedParameterValue.class); verify(query).setParameter(eq(1), captor.capture()); TypedParameterValue captorValue = captor.getValue(); - assertThat(captorValue.getType().getBindableJavaType()).isEqualTo(Integer.class); + assertThat(captorValue.type().getJavaType()).isEqualTo(Integer.class); assertThat(captorValue.getValue()).isNull(); } private void bind(JpaParameters parameters, JpaParametersParameterAccessor accessor) { - ParameterBinderFactory.createBinder(parameters) - .bind( // - QueryParameterSetter.BindableQuery.from(query), // - accessor, // - QueryParameterSetter.ErrorHandling.LENIENT // - ); + ParameterBinderFactory.createBinder(parameters, true).bind( // + QueryParameterSetter.BindableQuery.from(query), // + accessor, // + QueryParameterSetter.ErrorHandling.LENIENT // + ); } interface SampleRepository { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersUnitTests.java index ea851bb4fe..c798acd8ac 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaParametersUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. 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..b59a44a3a1 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryCreatorTests.java @@ -0,0 +1,1112 @@ +/* + * 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.Range; +import org.springframework.data.domain.Score; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Vector; +import org.springframework.data.jpa.domain.sample.EmbeddedIdExampleDepartment; +import org.springframework.data.jpa.domain.sample.EmbeddedIdExampleEmployee; +import org.springframework.data.jpa.domain.sample.EmbeddedIdExampleEmployeePK; +import org.springframework.data.jpa.domain.sample.ReferencingEmbeddedIdExampleEmployee; +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 + * @author Mark Paluch + */ +class JpaQueryCreatorTests { + + private static final TestMetaModel ORDER = TestMetaModel.hibernateModel(Order.class, LineItem.class, Product.class); + private static final TestMetaModel PERSON = TestMetaModel.hibernateModel(Person.class); + private static final TestMetaModel REFERENCE_IDS = TestMetaModel.hibernateModel( + ReferencingEmbeddedIdExampleEmployee.class, EmbeddedIdExampleEmployee.class, EmbeddedIdExampleEmployeePK.class, + EmbeddedIdExampleDepartment.class); + + static List 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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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)", DefaultJpaEntityMetadata.unqualify(Order.class), + 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)", + DefaultJpaEntityMetadata.unqualify(Product.class), 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)", + DefaultJpaEntityMetadata.unqualify(Product.class), + 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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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)", DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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)", DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class), + 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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class), + 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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class), + 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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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 '\\'", + DefaultJpaEntityMetadata.unqualify(Product.class), + 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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class), + 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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Order.class)) // + .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(), DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Product.class)) // + .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", + DefaultJpaEntityMetadata.unqualify(Person.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Person.class)) // + .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", DefaultJpaEntityMetadata.unqualify(Person.class)) // + .validateQuery(); + } + + @Test // GH-3588 + void doesNotCreateJoinForRelationshipEmbeddedId() { + + queryCreator(REFERENCE_IDS) // + .forTree(ReferencingEmbeddedIdExampleEmployee.class, "findByEmployee_EmployeePk_EmployeeId") // + .withParameters(1L) // + .as(QueryCreatorTester::create) // + .expectJpql( + "SELECT r FROM ReferencingEmbeddedIdExampleEmployee r WHERE r.employee.employeePk.employeeId = ?1") // + .validateQuery(); + } + + @Test // GH-3588 + void createsJoinForReferenceName() { + + queryCreator(REFERENCE_IDS) // + .forTree(ReferencingEmbeddedIdExampleEmployee.class, "findByEmployee_Department_Name") // + .withParameters("foo") // + .as(QueryCreatorTester::create) // + .expectJpql( + "SELECT r FROM ReferencingEmbeddedIdExampleEmployee r LEFT JOIN r.employee e LEFT JOIN e.department d WHERE d.name = ?1") // + .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, false, returnedType, parameterMetadataProvider, templates, + entityManager.getMetamodel()); + } + + @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 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 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 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 as(Function 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 queryCreator = Lazy.of(this::initJpaQueryCreator); + private Lazy 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 Vector getVector() { + return null; + } + + @Override + public @Nullable Score getScore() { + return null; + } + + @Override + public @Nullable Range getScoreRange() { + return null; + } + + @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 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/JpaQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancerUnitTests.java new file mode 100644 index 0000000000..f455e71be2 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancerUnitTests.java @@ -0,0 +1,82 @@ +/* + * 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.*; + +import java.util.function.Function; +import java.util.stream.Stream; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +/** + * Unit tests for {@link JpaQueryEnhancer}. + * + * @author Mark Paluch + */ +class JpaQueryEnhancerUnitTests { + + @ParameterizedTest // GH-3997 + @MethodSource("queryEnhancers") + void shouldRemoveCommentsFromJpql(Function> enhancerFunction) { + + QueryEnhancer enhancer = enhancerFunction + .apply("SELECT /* foo */ some_alias FROM /* some other */ table_name some_alias"); + + assertThat(enhancer.getQuery().getQueryString()) + .isEqualToIgnoringCase("SELECT some_alias FROM table_name some_alias"); + + enhancer = enhancerFunction.apply(""" + SELECT /* multi + line + comment + */ some_alias FROM /* some other */ table_name some_alias + """); + + assertThat(enhancer.getQuery().getQueryString()) + .isEqualToIgnoringCase("SELECT some_alias FROM table_name some_alias"); + } + + @ParameterizedTest // GH-2856 + @MethodSource("queryEnhancers") + @SuppressWarnings("NullableProblems") + void detectsQueryType(Function> enhancerFunction) { + + JpaQueryEnhancer select = enhancerFunction.apply("SELECT some_alias FROM table_name some_alias"); + assertThat(select.getQueryInformation().getStatementType()).isEqualTo(QueryInformation.StatementType.SELECT); + + JpaQueryEnhancer from = enhancerFunction.apply("FROM table_name some_alias"); + assertThat(from.getQueryInformation().getStatementType()).isEqualTo(QueryInformation.StatementType.SELECT); + + JpaQueryEnhancer delete = enhancerFunction.apply("DELETE FROM table_name some_alias"); + assertThat(delete.getQueryInformation().getStatementType()).isEqualTo(QueryInformation.StatementType.DELETE); + + JpaQueryEnhancer update = enhancerFunction + .apply("UPDATE table_name some_alias SET some_alias.property = ?1"); + assertThat(update.getQueryInformation().getStatementType()).isEqualTo(QueryInformation.StatementType.UPDATE); + + if (((Object) select) instanceof JpaQueryEnhancer.HqlQueryParser) { + + JpaQueryEnhancer insert = enhancerFunction.apply("INSERT Person(name) VALUES(?1)"); + assertThat(insert.getQueryInformation().getStatementType()).isEqualTo(QueryInformation.StatementType.INSERT); + } + } + + static Stream>> queryEnhancers() { + return Stream.of(JpaQueryEnhancer::forHql, JpaQueryEnhancer::forEql, JpaQueryEnhancer::forJpql); + } +} 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 da57f6a899..6dfa8c2be3 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -39,6 +39,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 +170,7 @@ void allowsMethodReturnTypesForModifyingQuery() { @Test void modifyingExecutionRejectsNonIntegerOrVoidReturnType() { - when(method.getReturnType()).thenReturn((Class) Long.class); + when(method.getReturnType()).thenReturn((Class) String.class); assertThatIllegalArgumentException().isThrownBy(() -> new ModifyingExecution(method, em)); } @@ -182,7 +183,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 +199,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 +215,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 +230,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 +247,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 +264,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 5267425a50..0c76c343c1 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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -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; @@ -45,10 +45,11 @@ import org.springframework.data.repository.core.NamedQueries; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; +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.QueryMethodEvaluationContextProvider; import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.ValueExpressionDelegate; /** * Unit tests for {@link JpaQueryLookupStrategy}. @@ -58,12 +59,14 @@ * @author Jens Schauder * @author Réda Housni Alaoui * @author Greg Turnquist + * @author Mark Paluch */ @ExtendWith(MockitoExtension.class) @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 +74,6 @@ class JpaQueryLookupStrategyUnitTests { @Mock NamedQueries namedQueries; @Mock Metamodel metamodel; @Mock ProjectionFactory projectionFactory; - @Mock BeanFactory beanFactory; private JpaQueryMethodFactory queryMethodFactory; @@ -89,37 +91,19 @@ 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); - Throwable reference = new RuntimeException(); - when(em.createQuery(anyString())).thenThrow(reference); - - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> strategy.resolveQuery(method, metadata, projectionFactory, namedQueries)) - .withCause(reference); - } - - @Test // DATAJPA-554 - void sholdThrowMorePreciseExceptionIfTryingToUsePaginationInNativeQueries() throws Exception { - - QueryLookupStrategy strategy = JpaQueryLookupStrategy.create(em, queryMethodFactory, Key.CREATE_IF_NOT_FOUND, - EVALUATION_CONTEXT_PROVIDER, new BeanFactoryQueryRewriterProvider(beanFactory), EscapeCharacter.DEFAULT); - Method method = UserRepository.class.getMethod("findByInvalidNativeQuery", String.class, Sort.class); - RepositoryMetadata metadata = new DefaultRepositoryMetadata(UserRepository.class); - - assertThatExceptionOfType(InvalidJpaQueryMethodException.class) - .isThrownBy(() -> strategy.resolveQuery(method, metadata, projectionFactory, namedQueries)) - .withMessageContaining("Cannot use native queries with dynamic sorting in method") - .withMessageContaining(method.toString()); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> strategy.resolveQuery(method, metadata, projectionFactory, namedQueries)); } @Test // GH-2217 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"); @@ -141,7 +125,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"); @@ -160,7 +144,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); @@ -173,15 +157,15 @@ 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); - assertThatIllegalStateException() + assertThatExceptionOfType(QueryCreationException.class) .isThrownBy(() -> strategy.resolveQuery(method, metadata, projectionFactory, namedQueries)) .withMessageContaining( - "is backed by a NamedQuery and must not contain a sort parameter as we cannot modify the query; Use @Query instead"); + "is backed by a NamedQuery and must not contain a sort parameter as we cannot modify the query; Use @Query(value=…) instead to apply sorting or remove the 'Sort' parameter."); } @Test // GH-2018 @@ -198,7 +182,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); @@ -235,16 +219,13 @@ interface UserRepository extends Repository { @Query("something absurd") User findByFoo(String foo); - @Query(value = "select u.* from User u", nativeQuery = true) - List findByInvalidNativeQuery(String param, Sort sort); - @Query(countName = "foo.count") Page findByNamedQuery(String foo, Pageable pageable); @Query(value = "select foo from Foo foo", countName = "foo.count") Page findByStringQueryWithNamedCountQuery(String foo, Pageable pageable); - @Query(value = "something absurd", name = "my-query-name") + @Query(value = "select foo from Foo foo", name = "my-query-name") User annotatedQueryWithQueryAndQueryName(); @Query("SELECT * FROM table WHERE (json_col->'jsonKey')::jsonb \\?\\? :param ") diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryMethodUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryMethodUnitTests.java index eee3095181..93f01a109c 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryMethodUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryMethodUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -34,6 +34,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; + import org.springframework.core.annotation.AliasFor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -282,20 +283,6 @@ void returnsDefaultCountQueryNameBasedOnConfiguredNamedQueryName() throws Except assertThat(method.getNamedCountQueryName()).isEqualTo("HateoasAwareSpringDataWebConfiguration.bar.count"); } - @Test // DATAJPA-185 - void rejectsInvalidNamedParameter() { - - assertThatThrownBy(() -> getQueryMethod(InvalidRepository.class, "findByAnnotatedQuery", String.class)) - .isInstanceOf(IllegalStateException.class) - // Parameter from query - .hasMessageContaining("foo") - // Parameter name from annotation - .hasMessageContaining("param") - // Method name - .hasMessageContaining("findByAnnotatedQuery"); - - } - @Test // DATAJPA-207 @SuppressWarnings({ "rawtypes", "unchecked" }) void returnsTrueIfReturnTypeIsEntity() { @@ -529,9 +516,6 @@ interface InvalidRepository extends Repository { @Modifying void updateMethod(String firstname, Sort sort); - // Typo in named parameter - @Query("select u from User u where u.firstname = :foo") - List findByAnnotatedQuery(@Param("param") String param); } interface ValidRepository extends Repository { 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 4c504e8cc9..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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -15,22 +15,25 @@ */ 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; import java.util.List; import java.util.Map; +import java.util.Set; 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; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.FilterType; import org.springframework.context.annotation.ImportResource; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -40,11 +43,14 @@ 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 QueryRewrite}. + * Unit tests for repository with {@link Query} and {@link QueryRewriter}. * * @author Greg Turnquist * @author Krzysztof Krason @@ -54,16 +60,27 @@ class JpaQueryRewriteIntegrationTests { @Autowired private UserRepositoryWithRewriter repository; + @Autowired private JpaRepositoryFactoryBean factoryBean; // Results static final String ORIGINAL_QUERY = "original query"; static final String REWRITTEN_QUERY = "rewritten query"; static final String SORT = "sort"; static Map results = new HashMap<>(); + static Set queries = new LinkedHashSet<>(); @BeforeEach void setUp() { results.clear(); + repository.deleteAll(); + } + + @Test + void shouldConfigureQueryEnhancerSelector() { + + JpaRepositoryFactory factory = (JpaRepositoryFactory) ReflectionTestUtils.getField(factoryBean, "factory"); + + assertThat(factory).extracting("queryEnhancerSelector").isInstanceOf(MyQueryEnhancerSelector.class); } @Test @@ -77,15 +94,15 @@ void nativeQueryShouldHandleRewrites() { entry(SORT, Sort.unsorted().toString())); } - @Test + @Test // GH-3801 void nonNativeQueryShouldHandleRewrites() { - repository.findByNonNativeQuery("Matthews"); + repository.save(new User("D", "A", "foo@bar")); - assertThat(results).containsExactly( // - entry(ORIGINAL_QUERY, "select original_user_alias from User original_user_alias"), // - entry(REWRITTEN_QUERY, "select rewritten_user_alias from User rewritten_user_alias"), // - entry(SORT, Sort.unsorted().toString())); + repository.findByNonNativeQuery("Matthews", PageRequest.of(0, 1)); + + assertThat(queries).contains("select original_user_alias from User original_user_alias"); + assertThat(queries).contains("select count(original_user_alias) from User original_user_alias"); } @Test @@ -169,7 +186,7 @@ public interface UserRepositoryWithRewriter List findByNativeQuery(String param); @Query(value = "select original_user_alias from User original_user_alias", queryRewriter = TestQueryRewriter.class) - List findByNonNativeQuery(String param); + Page findByNonNativeQuery(String param, PageRequest pageRequest); @Query(value = "select original_user_alias from User original_user_alias", queryRewriter = TestQueryRewriter.class) List findByNonNativeSortedQuery(String param, Sort sort); @@ -214,6 +231,7 @@ private static String replaceAlias(String query, Sort sort) { results.put(ORIGINAL_QUERY, query); results.put(REWRITTEN_QUERY, rewrittenQuery); results.put(SORT, sort.toString()); + queries.add(query); return rewrittenQuery; } @@ -222,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 @@ -231,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 f858594973..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlComplianceTests.java +++ /dev/null @@ -1,74 +0,0 @@ -/* - * 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 static org.assertj.core.api.Assertions.*; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; - -import org.antlr.v4.runtime.CharStreams; -import org.antlr.v4.runtime.CommonTokenStream; -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) { - - JpqlLexer lexer = new JpqlLexer(CharStreams.fromString(query)); - JpqlParser parser = new JpqlParser(new CommonTokenStream(lexer)); - - parser.addErrorListener(new BadJpqlGrammarErrorListener(query)); - - JpqlParser.StartContext parsedQuery = parser.start(); - - return render(new JpqlQueryRenderer().visit(parsedQuery)); - } - - 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 new file mode 100644 index 0000000000..a82aaf7581 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java @@ -0,0 +1,41 @@ +/* + * 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 org.antlr.v4.runtime.tree.ParseTreeVisitor; + +import org.springframework.data.domain.Sort; +import org.springframework.data.repository.query.QueryMethod; + +/** + * Unit tests for {@link DtoProjectionTransformerDelegate}. + * + * @author Mark Paluch + */ +class JpqlDtoQueryTransformerUnitTests extends AbstractDtoQueryTransformerUnitTests { + + @Override + JpaQueryEnhancer.JpqlQueryParser parse(String query) { + return JpaQueryEnhancer.JpqlQueryParser.parseQuery(query); + } + + @Override + ParseTreeVisitor getTransformer(JpaQueryEnhancer.JpqlQueryParser parser, QueryMethod method) { + return new JpqlSortedQueryTransformer(Sort.unsorted(), parser.getQueryInformation(), + method.getResultProcessor().getReturnedType()); + } + +} 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 a026e90366..2d86aa5761 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -21,18 +21,18 @@ import org.junit.jupiter.params.provider.MethodSource; /** - * TCK Tests for {@link JpqlQueryParser} mixed into {@link JpaQueryEnhancer}. + * TCK Tests for {@link JpaQueryEnhancer.JpqlQueryParser} mixed into {@link JpaQueryEnhancer}. * * @author Greg Turnquist */ -public class JpqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests { +class JpqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests { @Override QueryEnhancer createQueryEnhancer(DeclaredQuery query) { - assumeThat(query.isNativeQuery()).isFalse(); + assumeThat(query.isNative()).describedAs("JPQL (non-native) only").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..78721ae6fe --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlQueryBuilderUnitTests.java @@ -0,0 +1,297 @@ +/* + * 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.assertj.core.api.AbstractStringAssert; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link JpqlQueryBuilder}. + * + * @author Christoph Strobl + * @author Mark Paluch + * @author Choi Wang Gyu + */ +class JpqlQueryBuilderUnitTests { + + @Test // GH-3588 + void placeholdersRenderCorrectly() { + + assertThatRendered(JpqlQueryBuilder.parameter(ParameterPlaceholder.indexed(1))).isEqualTo("?1"); + assertThatRendered(JpqlQueryBuilder.parameter(ParameterPlaceholder.named("arg1"))) + .isEqualTo(":arg1"); + assertThatRendered(JpqlQueryBuilder.parameter("?1")).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() { + + assertThatRendered(literal("literal")).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'. */ + assertThatRendered(literal("literal's")).isEqualTo("'literal''s'"); + } + + @Test // GH-3588 + void entity() { + + Entity entity = entity(Order.class); + + assertThat(entity.getAlias()).isEqualTo("o"); + assertThat(entity.getName()).isEqualTo(getClass().getSimpleName() + "$" + Order.class.getSimpleName()); + } + + @Test // GH-4032 + void considersEntityName() { + + Entity entity = entity(Product.class); + + assertThat(entity.getAlias()).isEqualTo("p"); + assertThat(entity.getName()).isEqualTo("my_product"); + } + + @Test // GH-3588 + void literalExpressionRendersAsIs() { + Expression expression = expression("CONCAT(person.lastName, ‘, ’, person.firstName))"); + assertThatRendered(expression).isEqualTo("CONCAT(person.lastName, ‘, ’, person.firstName))"); + } + + @Test // GH-3961 + void aliasedExpression() { + + // aliasing is contextual and happens during selection rendering. E.g. constructor expressions don't use aliases. + Expression expression = expression("CONCAT(person.lastName, ‘, ’, person.firstName)").as("concatted"); + assertThatRendered(expression) + .isEqualTo("CONCAT(person.lastName, ‘, ’, person.firstName)"); + } + + @Test // GH-3961 + void shouldRenderDateAsJpqlLiteral() { + + Entity entity = 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 = entity(Order.class); + WhereStep where = JpqlQueryBuilder.where(JpqlQueryBuilder.path(entity, "country")); + ContextualAssert ctx = contextual(ctx(entity)); + + ctx.assertThat(where.between(expression("'AT'"), expression("'DE'"))) + .isEqualTo("o.country BETWEEN 'AT' AND 'DE'"); + ctx.assertThat(where.eq(expression("'AT'"))).isEqualTo("o.country = 'AT'"); + ctx.assertThat(where.eq(literal("AT"))).isEqualTo("o.country = 'AT'"); + ctx.assertThat(where.gt(expression("'AT'"))).isEqualTo("o.country > 'AT'"); + ctx.assertThat(where.gte(expression("'AT'"))).isEqualTo("o.country >= 'AT'"); + + ctx.assertThat(where.in(expression("'AT', 'DE'"))).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')"); // + ctx.assertThat(where.isEmpty()).isEqualTo("o.country IS EMPTY"); + ctx.assertThat(where.isNotEmpty()).isEqualTo("o.country IS NOT EMPTY"); + ctx.assertThat(where.isTrue()).isEqualTo("o.country = TRUE"); + ctx.assertThat(where.isFalse()).isEqualTo("o.country = FALSE"); + ctx.assertThat(where.isNull()).isEqualTo("o.country IS NULL"); + ctx.assertThat(where.isNotNull()).isEqualTo("o.country IS NOT NULL"); + ctx.assertThat(where.like("'\\_%'", "" + EscapeCharacter.DEFAULT.getEscapeCharacter())) + .isEqualTo("o.country LIKE '\\_%' ESCAPE '\\'"); + ctx.assertThat(where.notLike(expression("'\\_%'"), "" + EscapeCharacter.DEFAULT.getEscapeCharacter())) + .isEqualTo("o.country NOT LIKE '\\_%' ESCAPE '\\'"); + ctx.assertThat(where.lt(expression("'AT'"))).isEqualTo("o.country < 'AT'"); + ctx.assertThat(where.lte(expression("'AT'"))).isEqualTo("o.country <= 'AT'"); + ctx.assertThat(where.memberOf(expression("'AT'"))).isEqualTo("'AT' MEMBER OF o.country"); + + // TODO: can we have this where.value(foo).memberOf(pathAndOrigin); + ctx.assertThat(where.notMemberOf(expression("'AT'"))).isEqualTo("'AT' NOT MEMBER OF o.country"); + ctx.assertThat(where.neq(expression("'AT'"))).isEqualTo("o.country != 'AT'"); + } + + @Test // GH-3961 + void inPredicateWithNestedExpression() { + + Entity entity = entity(Order.class); + WhereStep where = JpqlQueryBuilder.where(JpqlQueryBuilder.path(entity, "country")); + ContextualAssert ctx = contextual(ctx(entity)); + + // Test regular IN predicate with parentheses + ctx.assertThat(where.in(expression("'AT', 'DE'"))).isEqualTo("o.country IN ('AT', 'DE')"); + + // Test IN predicate with already parenthesized expression - should avoid double parentheses + Expression parenthesizedExpression = expression("('AT', 'DE')"); + ctx.assertThat(where.in(parenthesizedExpression)) + .isEqualTo("o.country IN ('AT', 'DE')"); + + // Test NOT IN predicate with already parenthesized expression + ctx.assertThat(where.notIn(parenthesizedExpression)) + .isEqualTo("o.country NOT IN ('AT', 'DE')"); + + // Test IN with subquery (already parenthesized) + Expression subqueryExpression = expression("(SELECT c.code FROM Country c WHERE c.active = true)"); + ctx.assertThat(where.in(subqueryExpression)) + .isEqualTo("o.country IN (SELECT c.code FROM Country c WHERE c.active = true)"); + } + + @Test // GH-3588 + void selectRendering() { + + // make sure things are immutable + SelectStep select = JpqlQueryBuilder.selectFrom(entity(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(entity(Order.class)) + .select(JpqlQueryBuilder.path(entity(Order.class), "country")).render()) + .startsWith("SELECT o.country "); + } + + @Test // GH-3588 + void joins() { + + Entity entity = 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 = 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 ContextualAssert contextual(RenderContext context) { + return new ContextualAssert(context); + } + + static AbstractStringAssert assertThatRendered(Renderable renderable) { + return contextual(RenderContext.EMPTY).assertThat(renderable); + } + + static AbstractStringAssert assertThat(String actual) { + return Assertions.assertThat(actual); + } + + record ContextualAssert(RenderContext context) { + + public AbstractStringAssert assertThat(Renderable renderable) { + return Assertions.assertThat(renderable.render(context)); + } + } + + static RenderContext ctx(Entity... entities) { + + Map aliases = new LinkedHashMap<>(entities.length); + for (Entity entity : entities) { + aliases.put(entity, entity.getAlias()); + } + + return new RenderContext(aliases); + } + + /** + * Create an {@link Entity} from the given {@link Class entity class}. + * + * @param from the entity type to select from. + * @return + */ + static Entity entity(Class from) { + return JpqlQueryBuilder.entity(new DefaultJpaEntityMetadata<>(from)); + } + + @jakarta.persistence.Entity + static class Order { + + @Id Long id; + Date date; + String country; + + @OneToMany List 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(name = "my_product") + 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 71836ed390..707cbaf536 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -16,12 +16,9 @@ package org.springframework.data.jpa.repository.query; import static org.assertj.core.api.Assertions.*; -import static org.springframework.data.jpa.repository.query.JpaQueryParsingToken.*; 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; @@ -29,6 +26,8 @@ import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; +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
      @@ -37,6 +36,7 @@ * * @author Greg Turnquist * @author Christoph Strobl + * @author Mark Paluch * @since 3.1 */ class JpqlQueryRendererTests { @@ -44,18 +44,13 @@ class JpqlQueryRendererTests { 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}. + * Parse the query using {@link JpqlParser} then run it through the query-preserving {@link JpqlQueryRenderer}. */ private static String parseWithoutChanges(String query) { - JpqlLexer lexer = new JpqlLexer(CharStreams.fromString(query)); - JpqlParser parser = new JpqlParser(new CommonTokenStream(lexer)); - - parser.addErrorListener(new BadJpqlGrammarErrorListener(query)); - - JpqlParser.StartContext parsedQuery = parser.start(); + JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser.parseQuery(query); - return render(new JpqlQueryRenderer().visit(parsedQuery)); + return TokenRenderer.render(new JpqlQueryRenderer().visit(parser.getContext())); } static Stream reservedWords() { @@ -75,6 +70,281 @@ 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"); + assertQuery("SELECT e FROM Employee e JOIN FETCH e.address ORDER BY city"); + } + + @Test + void leftJoin() { + assertQuery("SELECT e FROM Employee e LEFT JOIN e.address a ORDER BY a.city"); + } + + @Test // GH-3902 + void fromCollection() { + + assertQuery("SELECT e FROM Employee e, IN(e.projects) AS p"); + assertQuery("SELECT e FROM Employee e, IN(e.projects) p"); + assertQuery("SELECT e FROM Employee e, IN(e.projects)"); + + assertQuery("FROM Employee e, IN(e.projects)"); + } + + @Test // GH-3902 + void fromSubquery() { + + assertQuery("SELECT e FROM Employee e, (SELECT p FROM Project p) AS sub"); + assertQuery("SELECT e FROM Employee e, (SELECT p FROM Project p) sub"); + assertQuery("SELECT e FROM Employee e, (SELECT p FROM Project p)"); + assertQuery("FROM Employee e, (SELECT p FROM Project p) sub"); + } + + @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 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 */ @@ -296,7 +566,7 @@ void fromClauseDowncastingExample1() { assertQuery(""" SELECT b.name, b.ISBN FROM Order o JOIN TREAT(o.product AS Book) b - """); + """); } @Test @@ -305,7 +575,7 @@ void fromClauseDowncastingExample2() { assertQuery(""" SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp WHERE lp.budget > 1000 - """); + """); } /** @@ -320,7 +590,7 @@ void fromClauseDowncastingExample3_SPEC_BUG() { WHERE TREAT(p AS LargeProject).budget > 1000 OR TREAT(p AS SmallProject).name LIKE 'Persist%' OR p.description LIKE "cost overrun" - """); + """); } @Test @@ -331,7 +601,7 @@ void fromClauseDowncastingExample3fixed() { WHERE TREAT(p AS LargeProject).budget > 1000 OR TREAT(p AS SmallProject).name LIKE 'Persist%' OR p.description LIKE 'cost overrun' - """); + """); } @Test @@ -341,7 +611,48 @@ void fromClauseDowncastingExample4() { SELECT e FROM Employee e WHERE TREAT(e AS Exempt).vacationDays > 10 OR TREAT(e AS Contractor).hours > 100 - """); + """); + } + + @Test // GH-3024, GH-3863 + void casting() { + + assertQuery(""" + select cast(i as string) from Item i where cast(i.date as date) <= cast(:currentDateTime as date) + """); + assertQuery("SELECT e FROM Employee e WHERE CAST(e.salary NUMERIC(10, 2)) > 0.0"); + } + + @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 @@ -405,7 +716,7 @@ void allExample() { WHERE emp.salary > ALL (SELECT m.salary FROM Manager m WHERE m.department = emp.department) - """); + """); } @Test @@ -417,7 +728,7 @@ void existsSubSelectExample2() { WHERE EXISTS (SELECT spouseEmp FROM Employee spouseEmp WHERE spouseEmp = emp.spouse) - """); + """); } @Test @@ -436,7 +747,7 @@ void subselectNumericComparisonExample2() { assertQuery(""" SELECT goodCustomer FROM Customer goodCustomer - WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed)/2.0 FROM Customer c) + WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed) / 2.0 FROM Customer c) """); } @@ -481,11 +792,11 @@ 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 + 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 @@ -494,11 +805,11 @@ 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 + CASE e.rating WHEN 1 THEN e.salary * 1.1 + WHEN 2 THEN e.salary * 1.05 + ELSE e.salary * 1.01 END - """); + """); } @Test @@ -538,7 +849,7 @@ void inClauseWithTypeLiteralsShouldWork() { SELECT e FROM Employee e WHERE TYPE(e) IN (Exempt, Contractor) - """); + """); } @Test @@ -561,6 +872,18 @@ WHERE TYPE(e) IN :empTypes """); } + @Test + void inClauseWithFunctionAndLiterals() { + + assertQuery(""" + select f from FooEntity f where upper(f.name) IN ('Y', 'Basic', 'Remit') + """); + assertQuery( + """ + select count(f) from FooEntity f where f.status IN (com.example.eql_bug_check.entity.FooStatus.FOO, com.example.eql_bug_check.entity.FooStatus.BAR) + """); + } + @Test void notEqualsForTypeShouldWork() { @@ -591,6 +914,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 @@ -735,7 +1066,7 @@ void orderByThatMatchesSelectClauseShouldWork() { void orderByThatMatchesAllSelectAliasesShouldWork() { assertQuery(""" - SELECT o.quantity, o.cost*1.08 AS taxedCost, a.zipcode + 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 @@ -929,6 +1260,14 @@ void findOrdersThatHaveProductNamedByAParameter() { """); } + @Test // GH-4013 + void minMaxFunctionsShouldWork() { + assertQuery("SELECT MAX(e.age), e.address.city FROM Employee e"); + assertQuery("SELECT MAX(1), e.address.city FROM Employee e"); + assertQuery("SELECT MAX(MIN(MOD(e.salary, 10))), e.address.city FROM Employee e"); + assertQuery("SELECT MIN(MOD(e.salary, 10)), e.address.city FROM Employee e"); + } + @Test // GH-2982 void floorShouldBeValidEntityName() { @@ -968,11 +1307,26 @@ void alternateNotEqualsOperatorShouldWork() { assertQuery("select e from Employee e where e.firstName != :name"); } + @Test + void regexShouldWork() { + assertQuery("select e from Employee e where e.lastName REGEXP '^Dr\\.*'"); + } + @Test // GH-3092 void dateAndFromShouldBeValidNames() { assertQuery("SELECT e FROM Entity e WHERE e.embeddedId.date BETWEEN :from AND :to"); } + @Test + void betweenStrings() { + assertQuery("SELECT e FROM Entity e WHERE e.embeddedId.date NOT BETWEEN 'a' AND 'b'"); + } + + @Test + void betweenDates() { + assertQuery("SELECT e FROM Entity e WHERE e.embeddedId.date BETWEEN CURRENT_DATE AND CURRENT_TIME"); + } + @Test // GH-3092 void timeShouldBeAValidParameterName() { assertQuery(""" @@ -1003,21 +1357,82 @@ 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", - "select count(u)*-0.7f as value from User u", "select count(oi) + (-100) as perc from StockOrderItem oi", + "select +1 as value from User u", "select +1 * -100 as value from User u", + "select count(u) * -0.7f as value from User u", "select count(oi) + (-100) as perc from StockOrderItem oi", "select p from Payment p where length(p.cardNumber) between +16 and -20" }) void signedLiteralShouldWork(String query) { assertQuery(query); } @ParameterizedTest // GH-3342 - @ValueSource(strings = { "select -count(u) from User u", "select +1*(-count(u)) from User u" }) + @ValueSource(strings = { "select -count(u) from User u", "select +1 * (-count(u)) from User u" }) void signedExpressionsShouldWork(String query) { assertQuery(query); } + @Test // GH-3873 + void escapeClauseShouldWork() { + assertQuery("select t.name from SomeDbo t where t.name LIKE :name escape '\\\\'"); + assertQuery("SELECT e FROM SampleEntity e WHERE LOWER(e.label) LIKE LOWER(?1) ESCAPE '\\\\'"); + assertQuery("SELECT e FROM SampleEntity e WHERE LOWER(e.label) LIKE LOWER(?1) ESCAPE ?1"); + assertQuery("SELECT e FROM SampleEntity e WHERE LOWER(e.label) LIKE LOWER(?1) ESCAPE :param"); + } + @ParameterizedTest // GH-3451 @MethodSource("reservedWords") void entityNameWithPackageContainingReservedWord(String reservedWord) { @@ -1026,4 +1441,45 @@ void entityNameWithPackageContainingReservedWord(String reservedWord) { assertQuery(source); } + @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-3834 + void reservedWordsShouldWork() { + + assertQuery("select ie from ItemExample ie left join ie.object io where io.externalId = :externalId"); + assertQuery("select ie.object from ItemExample ie left join ie.object io where io.externalId = :externalId"); + assertQuery("select ie from ItemExample ie left join ie.object io where io.object = :externalId"); + assertQuery("select ie from ItemExample ie where ie.status = com.app.domain.object.Status.UP"); + assertQuery("select f from FooEntity f where upper(f.name) IN :names"); + assertQuery("select f from FooEntity f where f.size IN :sizes"); + } + + @Test // GH-3902 + void queryWithoutSelectShouldWork() { + + assertQuery("from Person p"); + assertQuery("from Person p WHERE p.name = 'John' ORDER BY p.name"); + } + + @Test // GH-3902 + void queryWithoutSelectAndIdentificationVariableShouldWork() { + + assertQuery("from Person"); + assertQuery("from Person WHERE name = 'John' ORDER BY name"); + assertQuery("from Person JOIN department WHERE name = 'John' ORDER BY name"); + } + + @Test // GH-3902 + void queryWithoutIdentificationVariableShouldWork() { + + assertQuery("SELECT name, lastname from Person"); + assertQuery("SELECT name, lastname from Person WHERE lastname = 'Doe' ORDER BY name, lastname"); + assertQuery("SELECT name, lastname from Person JOIN department"); + } + } 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 72f0ff8b49..69b8514ed3 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -20,20 +20,24 @@ 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; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.dao.InvalidDataAccessApiUsageException; 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 - * {@link JpqlQueryParser}. + * {@link JpaQueryEnhancer.JpqlQueryParser}. * * @author Greg Turnquist + * @author Mark Paluch */ class JpqlQueryTransformerTests { @@ -71,6 +75,21 @@ void applyingSortShouldCreateAdditionalOrderByCriteria() { assertThat(results).contains("ORDER BY e.role, e.hire_date, e.first_name asc, e.last_name asc"); } + @Test // GH-1280 + void nullFirstLastSorting() { + + // given + var original = "SELECT e FROM Employee e where e.name = :name ORDER BY e.first_name asc NULLS FIRST"; + + assertThat(createQueryFor(original, Sort.unsorted())).isEqualTo(original); + + assertThat(createQueryFor(original, Sort.by(Order.desc("lastName").nullsLast()))).startsWith(original) + .endsWithIgnoringCase("e.lastName DESC NULLS LAST"); + + assertThat(createQueryFor(original, Sort.by(Order.desc("lastName").nullsFirst()))).startsWith(original) + .endsWithIgnoringCase("e.lastName DESC NULLS FIRST"); + } + @Test void applyCountToSimpleQuery() { @@ -84,6 +103,32 @@ void applyCountToSimpleQuery() { assertThat(results).isEqualTo("SELECT count(e) FROM Employee e where e.name = :name"); } + @Test // GH-3902 + void applyCountToFromQuery() { + + // given + var original = "FROM Employee e where e.name = :name"; + + // when + var results = createCountQueryFor(original); + + // then + assertThat(results).isEqualTo("select count(e) FROM Employee e where e.name = :name"); + } + + @Test // GH-3902 + void applyCountToFromQueryWithoutIdentificationVariable() { + + // given + var original = "FROM Employee where name = :name"; + + // when + var results = createCountQueryFor(original); + + // then + assertThat(results).isEqualTo("select count(__) FROM Employee AS __ where name = :name"); + } + @Test void applyCountToMoreComplexQuery() { @@ -97,8 +142,14 @@ void applyCountToMoreComplexQuery() { assertThat(results).isEqualTo("SELECT count(e) FROM Employee e where e.name = :name"); } + @Test // GH-3902 + void usesPrimaryAliasOfMultiselectForCountQuery() { + assertCountQuery("SELECT e.foo, e.bar FROM Employee e where e.name = :name ORDER BY e.modified_date", + "SELECT count(e) FROM Employee e where e.name = :name"); + } + @Test - void applyCountToAlreadySorteQuery() { + void applyCountToAlreadySortedQuery() { // given var original = "SELECT e FROM Employee e where e.name = :name ORDER BY e.modified_date"; @@ -123,8 +174,14 @@ void multipleAliasesShouldBeGathered() { assertThat(results).isEqualTo("select e from Employee e join e.manager m"); } - @Test + @Test // GH-3902 void createsCountQueryCorrectly() { + + assertCountQuery("SELECT id FROM Person", "SELECT count(id) FROM Person"); + assertCountQuery("SELECT p.id FROM Person p", "SELECT count(p) FROM Person p"); + assertCountQuery("SELECT id FROM Person p", "SELECT count(p) FROM Person p"); + assertCountQuery("SELECT id, name FROM Person", "SELECT count(id) FROM Person"); + assertCountQuery("SELECT id, name FROM Person p", "SELECT count(p) FROM Person p"); assertCountQuery(QUERY, COUNT_QUERY); } @@ -163,6 +220,14 @@ void createsCountQueryForQueriesWithSubSelects() { "select count(u) from User u left outer join u.roles r where r in (select r from Role r)"); } + @Test // GH-3902 + void createsCountQueryForQueriesWithoutVariableWithSubSelectsSelectQuery() { + + assertCountQuery( + "select name, (select foo from bar b) from User left outer join u.roles r where r in (select r from Role r)", + "select count(name) from User left outer join u.roles r where r in (select r from Role r)"); + } + @Test void createsCountQueryForAliasesCorrectly() { assertCountQuery("select u from User as u", "select count(u) from User as u"); @@ -173,7 +238,7 @@ void allowsShortJpaSyntax() { assertCountQuery(SIMPLE_QUERY, COUNT_QUERY); } - @Test // GH-2260 + @Test // GH-2260, GH-3902 void detectsAliasCorrectly() { assertThat(alias(QUERY)).isEqualTo("u"); @@ -189,7 +254,12 @@ void detectsAliasCorrectly() { assertThat(alias("select u from User u where not exists (select u2 from User u2)")).isEqualTo("u"); assertThat(alias( "select u from User u where not exists (select u2 from User u2 where not exists (select u3 from User u3))")) - .isEqualTo("u"); + .isEqualTo("u"); + assertThat(alias("select u, (select u2 from User u2) from User u")).isEqualTo("u"); + assertThat(alias("select firstname from User where not exists (select u2 from User u2)")).isNull(); + assertThat(alias("select firstname from User UNION select lastname from User b")).isNull(); + assertThat(alias("select firstname from User UNION select lastname from User UNION select lastname from User b")) + .isNull(); } @Test // GH-2557 @@ -197,29 +267,21 @@ 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(""" - select u - from user u - where exists (select u2 - from user u2 - ) - order by u.age desc"""); + """).rewrite(new DefaultQueryRewriteInformation(sort, + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())))) + .isEqualToIgnoringWhitespace(""" + select u + from user u + where exists (select u2 + from user u2 + ) + order by u.age desc"""); } @Test // GH-2563 @@ -360,7 +422,7 @@ void detectsComplexConstructorExpression() { 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 group by ip.id, ip.name, lp.accountId order by ip.name ASC""")) - .isTrue(); + .isTrue(); } @Test // DATAJPA-938 @@ -452,7 +514,7 @@ void doesNotPrefixAliasedFunctionCallNameWithDots() { String query = "SELECT AVG(m.price) AS m.avg FROM Magazine m"; Sort sort = Sort.by("m.avg"); - assertThatIllegalArgumentException().isThrownBy(() -> createQueryFor(query, sort)); + assertThatExceptionOfType(BadJpqlGrammarException.class).isThrownBy(() -> createQueryFor(query, sort)); } @Test // DATAJPA-965, DATAJPA-970, GH-2863 @@ -467,8 +529,8 @@ void doesNotPrefixAliasedFunctionCallNameWhenQueryStringContainsMultipleWhiteSpa @Test // DATAJPA-1506 void detectsAliasWithGroupAndOrderBy() { - assertThat(alias("select * from User group by name")).isNull(); - assertThat(alias("select * from User order by name")).isNull(); + // assertThat(alias("select * from User group by name")).isNull(); + // assertThat(alias("select * from User order by name")).isNull(); assertThat(alias("select u from User u group by name")).isEqualTo("u"); assertThat(alias("select u from User u order by name")).isEqualTo("u"); } @@ -476,9 +538,6 @@ void detectsAliasWithGroupAndOrderBy() { @Test // DATAJPA-1500 void createCountQuerySupportsWhitespaceCharacters() { - // - // - // assertThat(createCountQueryFor(""" select user from User user where user.age = 18 @@ -492,11 +551,6 @@ select count(user) from User user @Test void createCountQuerySupportsLineBreaksInSelectClause() { - // - // - // - // - // assertThat(createCountQueryFor(""" select user.age, user.name @@ -559,10 +613,6 @@ void appliesSortCorrectlyForSimpleField() { @Test void createCountQuerySupportsLineBreakRightAfterDistinct() { - // - // - // - // assertThat(createCountQueryFor(""" select distinct @@ -582,8 +632,10 @@ void createCountQuerySupportsLineBreakRightAfterDistinct() { @Test void detectsAliasWithGroupAndOrderByWithLineBreaks() { - assertThat(alias("select * from User group\nby name")).isNull(); - assertThat(alias("select * from User order\nby name")).isNull(); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User group\nby name")); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> alias("select * from User order\nby name")); assertThat(alias("select u from User u group\nby name")).isEqualTo("u"); assertThat(alias("select u from User u order\nby name")).isEqualTo("u"); assertThat(alias("select u from User\nu\norder \n by name")).isEqualTo("u"); @@ -606,7 +658,8 @@ void findProjectionClauseWithSubselect() { // This is not a required behavior, in fact the opposite is, // but it documents a current limitation. // to fix this without breaking findProjectionClauseWithIncludedFrom we need a more sophisticated parser. - assertThat(projection("select * from (select x from y)")).isNotEqualTo("*"); + assertThatExceptionOfType(BadJpqlGrammarException.class) + .isThrownBy(() -> projection("select * from (select x from y)")); } @Test // DATAJPA-1696 @@ -669,14 +722,14 @@ void countQueryUsesCorrectVariable() { assertThat( createCountQueryFor("SELECT t FROM mytable t WHERE nr = :number AND kon = :kon AND datum >= '2019-01-01'")) - .isEqualTo("SELECT count(t) FROM mytable t WHERE nr = :number AND kon = :kon AND datum >= '2019-01-01'"); + .isEqualTo("SELECT count(t) FROM mytable t WHERE nr = :number AND kon = :kon AND datum >= '2019-01-01'"); assertThat(createCountQueryFor("select s FROM users_statuses s WHERE (user_created_at BETWEEN $1 AND $2)")) .isEqualTo("select count(s) FROM users_statuses s WHERE (user_created_at BETWEEN $1 AND $2)"); assertThat( createCountQueryFor("SELECT us FROM users_statuses us WHERE (user_created_at BETWEEN :fromDate AND :toDate)")) - .isEqualTo("SELECT count(us) FROM users_statuses us WHERE (user_created_at BETWEEN :fromDate AND :toDate)"); + .isEqualTo("SELECT count(us) FROM users_statuses us WHERE (user_created_at BETWEEN :fromDate AND :toDate)"); } @Test // GH-3269 @@ -684,15 +737,31 @@ void createsCountQueryUsingAliasCorrectly() { assertCountQuery("select distinct 1 as x from Employee e", "select count(distinct 1) from Employee e"); assertCountQuery("SELECT DISTINCT abc AS x FROM T t", "SELECT count(DISTINCT abc) FROM T t"); - assertCountQuery("select distinct a as x, b as y from Employee e", "select count(distinct a , b) from Employee e"); + assertCountQuery("select distinct a as x, b as y from Employee e", "select count(distinct a, b) from Employee e"); assertCountQuery("select distinct sum(amount) as x from Employee e GROUP BY n", "select count(distinct sum(amount)) from Employee e GROUP BY n"); assertCountQuery("select distinct a, b, sum(amount) as c, d from Employee e GROUP BY n", - "select count(distinct a, b, sum(amount) , d) from Employee e GROUP BY n"); + "select count(distinct a, b, sum(amount), d) from Employee e GROUP BY n"); assertCountQuery("select distinct a, count(b) as c from Employee e GROUP BY n", "select count(distinct a, count(b)) from Employee e GROUP BY n"); } + @Test // GH-3902 + void createsCountQueryWithoutAlias() { + + assertCountQuery( + "SELECT this.quantity FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'", + "SELECT count(this.quantity) FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'"); + } + + @Test // GH-3902 + void createsCountQueryFromMultiselectWithoutAlias() { + + assertCountQuery( + "SELECT this.quantity, that.quantity FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'", + "SELECT count(this.quantity) FROM Order WHERE this.customer.firstname = 'John' AND this.customer.lastname = 'Wick'"); + } + @Test // GH-2496, GH-2522, GH-2537, GH-2045 void orderByShouldWorkWithSubSelectStatements() { @@ -736,7 +805,7 @@ void queryParserPicksCorrectAliasAmidstMultipleAliases() { @MethodSource("queriesWithReservedWordsAsIdentifiers") // GH-2864 void usingReservedWordAsRelationshipNameShouldWork(String relationshipName, String joinAlias) { - JpqlQueryParser.parseQuery(String.format(""" + JpaQueryEnhancer.JpqlQueryParser.parseQuery(String.format(""" select u from UserAccountEntity u join u.lossInspectorLimitConfiguration lil @@ -776,6 +845,16 @@ 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 queriesWithReservedWordsAsIdentifiers() { return Stream.of( // @@ -791,7 +870,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) { @@ -815,6 +895,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 44f57f2643..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlSpecificationTests.java +++ /dev/null @@ -1,888 +0,0 @@ -/* - * Copyright 2022-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 static org.assertj.core.api.Assertions.*; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -/** - * 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
      - *
      - * 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> "; - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example - */ - @Test - void joinExample1() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE e.contactInfo.address.zipcode = '95054' - """); - } - - @Test - void pathExpressionSyntaxExample1() { - - JpqlQueryParser.parseQuery(""" - SELECT DISTINCT l.product - FROM Order AS o JOIN o.lineItems l - """); - } - - @Test - void joinsExample1() { - - JpqlQueryParser.parseQuery(""" - SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize - """); - } - - @Test - void joinsExample2() { - - JpqlQueryParser.parseQuery(""" - SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInnerExample() { - - JpqlQueryParser.parseQuery(""" - SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInExample() { - - JpqlQueryParser.parseQuery(""" - SELECT OBJECT(c) FROM Customer c, IN(c.orders) o WHERE c.status = 1 - """); - } - - @Test - void doubleJoinExample() { - - JpqlQueryParser.parseQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE c.address.zipcode = '95054' - """); - } - - @Test - void leftJoinExample() { - - JpqlQueryParser.parseQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - GROUP BY s.name - """); - } - - @Test - void leftJoinOnExample() { - - JpqlQueryParser.parseQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - ON p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinWhereExample() { - - JpqlQueryParser.parseQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - WHERE p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinFetchExample() { - - JpqlQueryParser.parseQuery(""" - SELECT d - FROM Department d LEFT JOIN FETCH d.employees - WHERE d.deptno = 1 - """); - } - - @Test - void collectionMemberExample() { - - JpqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void collectionMemberInExample() { - - JpqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o, IN(o.lineItems) l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void fromClauseExample() { - - JpqlQueryParser.parseQuery(""" - SELECT o - FROM Order AS o JOIN o.lineItems l JOIN l.product p - """); - } - - @Test - void fromClauseDowncastingExample1() { - - JpqlQueryParser.parseQuery(""" - SELECT b.name, b.ISBN - FROM Order o JOIN TREAT(o.product AS Book) b - """); - } - - @Test - void fromClauseDowncastingExample2() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT e FROM Employee e - WHERE TREAT(e AS Exempt).vacationDays > 10 - OR TREAT(e AS Contractor).hours > 100 - """); - } - - @Test - void pathExpressionsNamedParametersExample() { - - JpqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE c.status = :stat - """); - } - - @Test - void betweenExpressionsExample() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void memberOfExample() { - - JpqlQueryParser.parseQuery(""" - SELECT p - FROM Person p - WHERE 'Joe' MEMBER OF p.nicknames - """); - } - - @Test - void existsSubSelectExample1() { - - JpqlQueryParser.parseQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS ( - SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void allExample() { - - JpqlQueryParser.parseQuery(""" - SELECT emp - FROM Employee emp - WHERE emp.salary > ALL ( - SELECT m.salary - FROM Manager m - WHERE m.department = emp.department) - """); - } - - @Test - void existsSubSelectExample2() { - - JpqlQueryParser.parseQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS ( - SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void subselectNumericComparisonExample1() { - - JpqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE (SELECT AVG(o.price) FROM c.orders o) > 100 - """); - } - - @Test - void subselectNumericComparisonExample2() { - - JpqlQueryParser.parseQuery(""" - SELECT goodCustomer - FROM Customer goodCustomer - WHERE goodCustomer.balanceOwed < ( - SELECT AVG(c.balanceOwed)/2.0 FROM Customer c) - """); - } - - @Test - void indexExample() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) - """); - } - - @Test - void functionInvocationExampleWithCorrection() { - - JpqlQueryParser.parseQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE - """); - } - - @Test - void updateCaseExample1() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (Exempt, Contractor) - """); - } - - @Test - void theRest2() { - - JpqlQueryParser.parseQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (:empType1, :empType2) - """); - } - - @Test - void theRest3() { - - JpqlQueryParser.parseQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN :empTypes - """); - } - - @Test - void theRest4() { - - JpqlQueryParser.parseQuery(""" - SELECT TYPE(e) - FROM Employee e - WHERE TYPE(e) <> Exempt - """); - } - - @Test - void theRest5() { - - JpqlQueryParser.parseQuery(""" - SELECT c.status, AVG(c.filledOrderCount), COUNT(c) - FROM Customer c - GROUP BY c.status - HAVING c.status IN (1, 2) - """); - } - - @Test - void theRest6() { - - JpqlQueryParser.parseQuery(""" - SELECT c.country, COUNT(c) - FROM Customer c - GROUP BY c.country - HAVING COUNT(c) > 30 - """); - } - - @Test - void theRest7() { - - JpqlQueryParser.parseQuery(""" - SELECT c, COUNT(o) - FROM Customer c JOIN c.orders o - GROUP BY c - HAVING COUNT(o) >= 5 - """); - } - - @Test - void theRest8() { - - JpqlQueryParser.parseQuery(""" - SELECT c.id, c.status - FROM Customer c JOIN c.orders o - WHERE o.count > 100 - """); - } - - @Test - void theRest9() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT o.lineItems FROM Order AS o - """); - } - - @Test - void theRest11() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT e.address AS addr - FROM Employee e - """); - } - - @Test - void theRest14() { - - JpqlQueryParser.parseQuery(""" - SELECT AVG(o.quantity) FROM Order o - """); - } - - @Test - void theRest15() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT COUNT(o) FROM Order o - """); - } - - @Test - void theRest17() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - 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(() -> { - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - """); - } - - @Test - void theRest26() { - - JpqlQueryParser.parseQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - AND c.orders IS EMPTY - """); - } - - @Test - void theRest27() { - - JpqlQueryParser.parseQuery(""" - UPDATE Customer c - SET c.status = 'outstanding' - WHERE c.balance < 10000 - """); - } - - @Test - void theRest28() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - """); - } - - @Test - void theRest30() { - - JpqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress.state = 'CA' - """); - } - - @Test - void theRest31() { - - JpqlQueryParser.parseQuery(""" - SELECT DISTINCT o.shippingAddress.state - FROM Order o - """); - } - - @Test - void theRest32() { - - JpqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - """); - } - - @Test - void theRest33() { - - JpqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS NOT EMPTY - """); - } - - @Test - void theRest34() { - - JpqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void theRest35() { - - JpqlQueryParser.parseQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.shipped = FALSE - """); - } - - @Test - void theRest36() { - - JpqlQueryParser.parseQuery(""" - 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() { - - JpqlQueryParser.parseQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress <> o.billingAddress - """); - } - - @Test - void theRest38() { - - JpqlQueryParser.parseQuery(""" - 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/KeysetScrollSpecificationUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecificationUnitTests.java index 39a43cb1d5..9d8c92a18a 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecificationUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/KeysetScrollSpecificationUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/LikeBindingUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/LikeBindingUnitTests.java index 54b2e8bad3..3000455292 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/LikeBindingUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/LikeBindingUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.*; import org.junit.jupiter.api.Test; + import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier; import org.springframework.data.jpa.repository.query.ParameterBinding.LikeParameterBinding; import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin; @@ -29,44 +30,45 @@ * @author Oliver Gierke * @author Thomas Darimont * @author Jens Schauder + * @author Mark Paluch */ class LikeBindingUnitTests { private static void assertAugmentedValue(Type type, Object value) { LikeParameterBinding binding = new LikeParameterBinding(BindingIdentifier.of("foo"), - ParameterOrigin.ofExpression("foo"), type); + ParameterOrigin.ofParameter(1), type); assertThat(binding.prepare("value")).isEqualTo(value); } @Test void rejectsNullName() { assertThatIllegalArgumentException() - .isThrownBy(() -> new LikeParameterBinding(null, ParameterOrigin.ofExpression(""), Type.CONTAINING)); + .isThrownBy(() -> new LikeParameterBinding(null, ParameterOrigin.ofParameter(0), Type.CONTAINING)); } @Test void rejectsEmptyName() { assertThatIllegalArgumentException().isThrownBy( - () -> new LikeParameterBinding(BindingIdentifier.of(""), ParameterOrigin.ofExpression(""), Type.CONTAINING)); + () -> new LikeParameterBinding(BindingIdentifier.of(""), ParameterOrigin.ofParameter(0), Type.CONTAINING)); } @Test void rejectsNullType() { assertThatIllegalArgumentException().isThrownBy( - () -> new LikeParameterBinding(BindingIdentifier.of("foo"), ParameterOrigin.ofExpression("foo"), null)); + () -> new LikeParameterBinding(BindingIdentifier.of("foo"), ParameterOrigin.ofParameter(0), null)); } @Test void rejectsInvalidType() { assertThatIllegalArgumentException().isThrownBy(() -> new LikeParameterBinding(BindingIdentifier.of("foo"), - ParameterOrigin.ofExpression("foo"), Type.SIMPLE_PROPERTY)); + ParameterOrigin.ofParameter(0), Type.SIMPLE_PROPERTY)); } @Test void rejectsInvalidPosition() { assertThatIllegalArgumentException().isThrownBy( - () -> new LikeParameterBinding(BindingIdentifier.of(0), ParameterOrigin.ofExpression(""), Type.CONTAINING)); + () -> new LikeParameterBinding(BindingIdentifier.of(0), ParameterOrigin.ofParameter(0), Type.CONTAINING)); } @Test diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodIntegrationTests.java index 8947bda883..ae76cb023a 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodUnitTests.java index b9e899121f..29e50b85b6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/MetaAnnotatedQueryMethodUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. 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 844ae69e01..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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -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 ebdd2a8395..b5a70c9099 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -40,6 +40,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; /** @@ -54,6 +55,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; @@ -88,7 +92,8 @@ void rejectsPersistenceProviderIfIncapableOfExtractingQueriesAndPagebleBeingUsed JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, projectionFactory, extractor); when(em.createNamedQuery(queryMethod.getNamedCountQueryName())).thenThrow(new IllegalArgumentException()); - assertThatExceptionOfType(QueryCreationException.class).isThrownBy(() -> NamedQuery.lookupFrom(queryMethod, em)); + assertThatExceptionOfType(QueryCreationException.class) + .isThrownBy(() -> NamedQuery.lookupFrom(queryMethod, em, CONFIG)); } @Test // DATAJPA-142 @@ -100,7 +105,7 @@ void doesNotRejectPersistenceProviderIfNamedCountQueryIsAvailable() { TypedQuery countQuery = mock(TypedQuery.class); when(em.createNamedQuery(eq(queryMethod.getNamedCountQueryName()), eq(Long.class))).thenReturn(countQuery); - NamedQuery query = (NamedQuery) NamedQuery.lookupFrom(queryMethod, em); + 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 new file mode 100644 index 0000000000..7768239163 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java @@ -0,0 +1,87 @@ +/* + * 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.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.metamodel.Metamodel; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.provider.QueryExtractor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; +import org.springframework.data.repository.query.ValueExpressionDelegate; +import org.springframework.util.ReflectionUtils; + +/** + * Unit tests for {@link NativeJpaQuery}. + * + * @author Mark Paluch + */ +@MockitoSettings(strictness = Strictness.LENIENT) +class NativeJpaQueryUnitTests { + + @Mock EntityManager em; + @Mock EntityManagerFactory emf; + @Mock Metamodel metamodel; + + @BeforeEach + void setUp() { + + when(em.getMetamodel()).thenReturn(metamodel); + when(em.getEntityManagerFactory()).thenReturn(emf); + when(em.getDelegate()).thenReturn(em); + } + + @Test // GH-3546 + void shouldApplySorting() { + + Method respositoryMethod = ReflectionUtils.findMethod(TestRepo.class, "find", Sort.class); + RepositoryMetadata repositoryMetadata = new DefaultRepositoryMetadata(TestRepo.class); + SpelAwareProxyProjectionFactory projectionFactory = mock(SpelAwareProxyProjectionFactory.class); + QueryExtractor queryExtractor = mock(QueryExtractor.class); + JpaQueryMethod queryMethod = new JpaQueryMethod(respositoryMethod, repositoryMetadata, projectionFactory, + queryExtractor); + + 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()); + + assertThat(sql.getQueryString()).isEqualTo("SELECT e FROM Employee e order by e.foo asc, e.bar asc"); + } + + interface TestRepo extends Repository { + + @Query("SELECT e FROM Employee e") + Object find(Sort sort); + } + +} 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 e62dc25bcc..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -39,6 +39,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; +import org.springframework.data.domain.Limit; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.Temporal; @@ -53,6 +54,7 @@ * @author Thomas Darimont * @author Jens Schauder * @author Mark Paluch + * @author Yanming Zhou */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -86,6 +88,8 @@ interface SampleRepository extends Repository { User validWithPageable(@Param("username") String username, Pageable pageable); + User validWithLimit(@Param("username") String username, Limit limit); + User validWithSort(@Param("username") String username, Sort sort); User validWithDefaultTemporalTypeParameter(@Temporal Date registerDate); @@ -122,6 +126,44 @@ void bindWorksWithNullForPageable() throws Exception { verify(query).setParameter(eq(1), eq("foo")); } + @Test // GH-3242 + void bindAndPrepareWorksWithPageable() throws Exception { + + Method validWithPageable = SampleRepository.class.getMethod("validWithPageable", String.class, Pageable.class); + Object[] values = { "foo", Pageable.ofSize(10).withPage(3) }; + + bindAndPrepare(validWithPageable, values); + + verify(query).setParameter(1, "foo"); + verify(query).setFirstResult(30); + verify(query).setMaxResults(10); + } + + @Test // GH-3242 + void bindWorksWithNullForLimit() throws Exception { + + Method validWithLimit = SampleRepository.class.getMethod("validWithLimit", String.class, Limit.class); + Object[] values = { "foo", null }; + + bind(validWithLimit, values); + + verify(query).setParameter(1, "foo"); + verify(query, never()).setFirstResult(anyInt()); + } + + @Test // GH-3242 + void bindAndPrepareWorksWithLimit() throws Exception { + + Method validWithLimit = SampleRepository.class.getMethod("validWithLimit", String.class, Limit.class); + Object[] values = { "foo", Limit.of(10) }; + + bindAndPrepare(validWithLimit, values); + + verify(query).setParameter(1, "foo"); + verify(query).setMaxResults(10); + verify(query, never()).setFirstResult(anyInt()); + } + @Test void usesIndexedParametersIfNoParamAnnotationPresent() { @@ -232,10 +274,15 @@ 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), false).bindAndPrepare(query, + getAccessor(method, values)); + } + private JpaParametersParameterAccessor getAccessor(Method method, Object... values) { return new JpaParametersParameterAccessor(createParameters(method), 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 b4bd22d9ad..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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -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 6d1d5393b9..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2017-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 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 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 6dc7b84b1c..963d742dd1 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 @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. @@ -26,8 +26,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.data.domain.Range; +import org.springframework.data.domain.Score; +import org.springframework.data.domain.Similarity; +import org.springframework.data.domain.Vector; 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; @@ -41,6 +45,7 @@ * * @author Oliver Gierke * @author Jens Schauder + * @author Mark Paluch * @soundtrack Elephants Crossing - We are (Irrelephant) */ @ExtendWith(SpringExtension.class) @@ -50,30 +55,78 @@ 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 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 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 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); + } + + @Test // GH- + void appliesScoreValuePreparation() throws Exception { + + ParameterMetadataProvider provider = createProvider( + Sample.class.getMethod("findByVectorWithin", Vector.class, Score.class)); + ParameterBinding.PartTreeParameterBinding vector = provider.next(new Part("VectorWithin", WithVector.class)); + ParameterBinding.PartTreeParameterBinding score = provider.next(new Part("VectorWithin", WithVector.class)); + ParameterMetadataProvider.ScoreParameterBinding binding = provider.normalize(score, SimilarityNormalizer.EUCLIDEAN); + + assertThat(binding.prepare(Score.of(1))).isEqualTo(0.0); + assertThat(binding.prepare(Score.of(0.5))).isEqualTo(1.0); + assertThat(provider.getBindings()).hasSize(2).contains(binding).doesNotContain(score); + } + + @Test // GH- + void appliesLowerRangeValuePreparation() throws Exception { + + ParameterMetadataProvider provider = createProvider( + Sample.class.getMethod("findByVectorWithin", Vector.class, Range.class)); + ParameterBinding.PartTreeParameterBinding vector = provider.next(new Part("VectorWithin", WithVector.class)); + ParameterBinding.PartTreeParameterBinding score = provider.next(new Part("VectorWithin", WithVector.class)); + ParameterMetadataProvider.ScoreParameterBinding lower = provider.lower(score, SimilarityNormalizer.EUCLIDEAN); + + Range range = Similarity.between(0.5, 1); + + assertThat(lower.prepare(range)).isEqualTo(1.0); + assertThat(provider.getBindings()).hasSize(2).contains(lower).doesNotContain(score); + } + + @Test // GH- + void appliesRangeValuePreparation() throws Exception { + + ParameterMetadataProvider provider = createProvider( + Sample.class.getMethod("findByVectorWithin", Vector.class, Range.class)); + ParameterBinding.PartTreeParameterBinding vector = provider.next(new Part("VectorWithin", WithVector.class)); + ParameterBinding.PartTreeParameterBinding score = provider.next(new Part("VectorWithin", WithVector.class)); + ParameterMetadataProvider.ScoreParameterBinding lower = provider.lower(score, SimilarityNormalizer.EUCLIDEAN); + ParameterMetadataProvider.ScoreParameterBinding upper = provider.upper(score, SimilarityNormalizer.EUCLIDEAN); + + Range range = Similarity.between(0.5, 1); + + assertThat(lower.prepare(range)).isEqualTo(1.0); + assertThat(upper.prepare(range)).isEqualTo(0.0); + assertThat(provider.getBindings()).hasSize(3).contains(lower, upper).doesNotContain(score); } private ParameterMetadataProvider createProvider(Method method) { @@ -81,7 +134,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" }) @@ -99,5 +153,13 @@ interface Sample { User findByLastname(String lastname); User findByAgeContaining(@Param("age") Integer age); + + User findByVectorWithin(Vector vector, Score score); + + User findByVectorWithin(Vector vector, Range score); + } + + static class WithVector { + Vector vector; } } 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 c62b6e8b09..30895c94b3 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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -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,37 +50,15 @@ 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))) // .withMessageContaining("parameter"); } - @Test // GH-3137 - void returnAugmentedValueForStringExpressions() { - - when(part.getProperty().getLeafProperty().isCollection()).thenReturn(false); - - assertThat(createParameterMetadata(Part.Type.STARTING_WITH).prepare("starting with")).isEqualTo("starting with%"); - assertThat(createParameterMetadata(Part.Type.ENDING_WITH).prepare("ending with")).isEqualTo("%ending with"); - assertThat(createParameterMetadata(Part.Type.CONTAINING).prepare("containing")).isEqualTo("%containing%"); - assertThat(createParameterMetadata(Part.Type.NOT_CONTAINING).prepare("not containing")) - .isEqualTo("%not containing%"); - assertThat(createParameterMetadata(Part.Type.LIKE).prepare("%like%")).isEqualTo("%like%"); - assertThat(createParameterMetadata(Part.Type.IS_NULL).prepare(null)).isEqualTo(null); - } - - @SuppressWarnings({ "rawtypes", "unchecked" }) - private ParameterMetadataProvider.ParameterMetadata createParameterMetadata(Part.Type partType) { - - when(part.getType()).thenReturn(partType); - return new ParameterMetadataProvider.ParameterMetadata<>(parameterExpression, part, null, EscapeCharacter.DEFAULT); - } } 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 ddd71dbfa7..3b1fe54569 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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License import org.springframework.aop.framework.Advised; @@ -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; @@ -37,9 +35,13 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; 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; @@ -48,6 +50,7 @@ import org.springframework.data.repository.Repository; import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; import org.springframework.data.repository.query.Param; +import org.springframework.data.repository.query.QueryCreationException; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -59,6 +62,7 @@ * @author Michael Cramer * @author Jens Schauder * @author Krzysztof Krason + * @author Christoph Strobl */ @ExtendWith(SpringExtension.class) @ContextConfiguration("classpath:infrastructure.xml") @@ -100,20 +104,21 @@ void cannotIgnoreCaseIfNotStringUnlessIgnoringAll() throws Exception { testIgnoreCase("findByIdAllIgnoringCase", 3); } - @Test // DATAJPA-121 - @Disabled // HHH-15432 - void recreatesQueryIfNullValueIsGiven() throws Exception { + @ParameterizedTest // DATAJPA-121, GH-3675 + @ValueSource(strings = { "Firstname", "FirstnameNot" }) + void recreatesQueryIfNullValueIsGiven(String criteria) throws Exception { - JpaQueryMethod queryMethod = getQueryMethod("findByFirstname", String.class, Pageable.class); + JpaQueryMethod queryMethod = getQueryMethod("findBy%s".formatted(criteria), String.class, Pageable.class); PartTreeJpaQuery jpaQuery = new PartTreeJpaQuery(queryMethod, entityManager); Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { "Matthews", PageRequest.of(0, 1) })); - - assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("firstname=:param0"); + assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))) + .contains("firstname %s :".formatted(criteria.endsWith("Not") ? "!=" : "=")); query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { null, PageRequest.of(0, 1) })); - assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))).endsWith("firstname is null"); + assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))) + .endsWithIgnoringCase("firstname %s NULL".formatted(criteria.endsWith("Not") ? "IS NOT" : "IS")); } @Test // DATAJPA-920 @@ -147,7 +152,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 @@ -158,7 +163,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 @@ -166,7 +182,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 @@ -176,8 +193,7 @@ void rejectsInPredicateWithNonIterableParameter() throws Exception { assertThatExceptionOfType(RuntimeException.class) // .isThrownBy(() -> new PartTreeJpaQuery(method, entityManager)) // - .withMessageContaining("findByIdIn") // - .withMessageContaining(" IN ") // + .withMessageContaining("'IN'") // .withMessageContaining("Collection") // .withMessageContaining("Integer"); } @@ -187,11 +203,10 @@ void rejectsOtherThanInPredicateWithIterableParameter() throws Exception { JpaQueryMethod method = getQueryMethod("findById", Collection.class); - assertThatExceptionOfType(RuntimeException.class) // + assertThatExceptionOfType(QueryCreationException.class) // .isThrownBy(() -> new PartTreeJpaQuery(method, entityManager)) // - .withMessageContaining("findById") // - .withMessageContaining(" SIMPLE_PROPERTY ") // - .withMessageContaining(" scalar ") // + .withMessageContaining("'SIMPLE_PROPERTY'") // + .withMessageContaining("scalar ") // .withMessageContaining("Collection"); } @@ -210,11 +225,9 @@ void errorsDueToMismatchOfParametersContainNameOfMethodInterfaceAndPropertyPath( JpaQueryMethod method = getQueryMethod("findByFirstname"); - assertThatExceptionOfType(IllegalArgumentException.class) // + assertThatExceptionOfType(QueryCreationException.class) // .isThrownBy(() -> new PartTreeJpaQuery(method, entityManager)) // - .withMessageContaining("findByFirstname") // the method being analyzed - .withMessageContaining(" firstname ") // the property we are looking for - .withMessageContaining("UserRepository"); // the repository + .withMessageContaining("'firstname'"); // the property we are looking for } @Test // DATAJPA-863 @@ -222,11 +235,9 @@ void errorsDueToMissingPropertyContainNameOfMethodAndInterface() throws Exceptio JpaQueryMethod method = getQueryMethod("findByNoSuchProperty", String.class); - assertThatExceptionOfType(IllegalArgumentException.class) // + assertThatExceptionOfType(QueryCreationException.class) // .isThrownBy(() -> new PartTreeJpaQuery(method, entityManager)) // - .withMessageContaining("findByNoSuchProperty") // the method being analyzed - .withMessageContaining("'noSuchProperty'") // the property we are looking for - .withMessageContaining("UserRepository"); // the repository + .withMessageContaining("'noSuchProperty'"); // the property we are looking for } @Test // GH-3356 @@ -277,6 +288,8 @@ interface UserRepository extends Repository { Page findByFirstname(String firstname, Pageable pageable); + Page findByFirstnameNot(String firstname, Pageable pageable); + User findByIdIgnoringCase(Integer id); User findByIdAllIgnoringCase(Integer id); @@ -291,6 +304,8 @@ interface UserRepository extends Repository { List findByFirstnameIsEmpty(); + List findByRolesContaining(Role role); + // should fail, since we can't compare scalar values to collections List findById(Collection 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> 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 61fe6a2aa3..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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -24,32 +24,36 @@ * * @author Diego Krupitza * @author Greg Turnquist + * @author Christoph Strobl + * @author Mark Paluch */ 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); JpaQueryEnhancer queryParsingEnhancer = (JpaQueryEnhancer) queryEnhancer; - assertThat(queryParsingEnhancer.getQueryParsingStrategy()).isInstanceOf(HqlQueryParser.class); + assertThat(queryParsingEnhancer).isInstanceOf(JpaQueryEnhancer.HqlQueryParser.class); } @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); } + } 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 3e3465e3d3..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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 the original author or authors. + * 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. @@ -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}", " ") @@ -68,6 +67,10 @@ static Stream nativeCountQueries() { "select u from User as u", // "select count(u) from User as u"), + Arguments.of( // + "SELECT id FROM Person", // + "select count(id) from Person"), + Arguments.of( // "SELECT u FROM User u where u.foo.bar = ?", // "select count(u) FROM User u where u.foo.bar = ?"), @@ -116,8 +119,7 @@ static Stream 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); @@ -176,9 +178,8 @@ static Stream 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); } @@ -202,11 +203,11 @@ static Stream 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 d476c445b2..f341b63e11 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 @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -18,10 +18,7 @@ import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assumptions.*; -import java.util.Arrays; import java.util.Collections; -import java.util.List; -import java.util.Set; import java.util.stream.Stream; import org.assertj.core.api.SoftAssertions; @@ -30,9 +27,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 +40,8 @@ * @author Diego Krupitza * @author Geoffrey Deremetz * @author Krzysztof Krason + * @author Mark Paluch + * @author Soomin Kim */ class QueryEnhancerUnitTests { @@ -78,9 +80,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 +91,21 @@ void detectsAliasWithUCorrectly(DeclaredQuery query, String alias) { public static Stream 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 +114,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 +134,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 +145,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 +161,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 +174,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,27 +183,26 @@ 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 + @Test // GH-2812, GH-2856 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"); + assertThatIllegalStateException().isThrownBy(() -> getEnhancer(query).createCountQueryFor("p.lastname")) + .withMessageContaining("Cannot derive count query for DELETE statement"); } @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 +211,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 +239,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 +268,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 +281,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 +290,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 aliases = QueryUtils.getOuterJoinAliases(queryString); - - StringQuery nativeQuery = new StringQuery(queryString, true); - Set 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 +435,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 +456,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 +469,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 +482,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 +495,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 +508,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 +544,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 +552,10 @@ void findProjectionClauseWithDistinct(DeclaredQuery query, String expected) { public static Stream 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 +573,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 aliases) { - - StringQuery nativeQuery = new StringQuery(queryString, true); - StringQuery nonNativeQuery = new StringQuery(queryString, false); - - Set nativeJoinAliases = getEnhancer(nativeQuery).getJoinAliases(); - Set 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,14 +599,15 @@ 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"); } - @Test // GH-2555 + @Test // GH-2555, GH-2856 void modifyingQueriesAreDetectedCorrectly() { String modifyingQuery = "update userinfo user set user.is_in_treatment = false where user.id = :userId"; @@ -626,30 +617,30 @@ 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); + + assertThatIllegalStateException().isThrownBy(() -> QueryEnhancer.create(modiQuery).createCountQueryFor(null)) + .withMessageContaining("Cannot derive count query for UPDATE statement"); } @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(); // queryutils results String queryUtilsDetectAlias = QueryUtils.detectAlias(insertQuery); String queryUtilsProjection = QueryUtils.getProjection(insertQuery); - String queryUtilsCountQuery = QueryUtils.createCountQueryFor(insertQuery); - Set queryUtilsOuterJoinAlias = QueryUtils.getOuterJoinAliases(insertQuery); // direct access assertThat(stringQuery.getAlias()).isEqualToIgnoringCase(queryUtilsDetectAlias); @@ -657,17 +648,17 @@ 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); + assertThatIllegalStateException().isThrownBy(() -> queryEnhancer.createCountQueryFor(null)) + .withMessageContaining("Cannot derive count query for INSERT statement"); + + assertThatIllegalStateException().isThrownBy(() -> queryEnhancer.rewrite(getRewriteInformation(sorting))) + .withMessageContaining("Cannot apply sorting to INSERT statement"); assertThat(queryEnhancer.detectAlias()).isEqualToIgnoringCase(queryUtilsDetectAlias); assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase(queryUtilsProjection); assertThat(queryEnhancer.hasConstructorExpression()).isFalse(); } - public static Stream insertStatementIsProcessedSameAsDefaultSource() { + static Stream insertStatementIsProcessedSameAsDefaultSource() { return Stream.of( // Arguments.of("INSERT INTO FOO(A) VALUES('A')"), // @@ -675,29 +666,68 @@ public static Stream insertStatementIsProcessedSameAsDefaultSource() ); } - public static Stream detectsJoinAliasesCorrectlySource() { + private static void assertCountQuery(String originalQuery, String countQuery, boolean nativeQuery) { + assertCountQuery(new TestEntityQuery(originalQuery, nativeQuery), countQuery); + } - return Stream.of( // - Arguments.of("select p from Person p left outer join x.foo b2_$ar", Collections.singletonList("b2_$ar")), // - Arguments.of("select p from Person p left join x.foo b2_$ar", Collections.singletonList("b2_$ar")), // - Arguments.of("select p from Person p left outer join x.foo as b2_$ar, left join x.bar as foo", - Arrays.asList("b2_$ar", "foo")), // - Arguments.of("select p from Person p left join x.foo as b2_$ar, left outer join x.bar foo", - Arrays.asList("b2_$ar", "foo")) // + private static void assertCountQuery(DefaultEntityQuery originalQuery, String countQuery) { + assertThat(getEnhancer(originalQuery).createCountQueryFor(null)).isEqualToIgnoringCase(countQuery); + } - ); + @Test // GH-2856 + void jpqlInsertQueryThrowsExceptionForCountQuery() { + + DeclaredQuery query = DeclaredQuery.jpqlQuery("INSERT INTO Foo(a) SELECT b.a FROM Bar b"); + QueryEnhancer enhancer = QueryEnhancer.create(query); + + assertThatIllegalStateException().isThrownBy(() -> enhancer.createCountQueryFor(null)) + .withMessageContaining("Cannot derive count query for INSERT statement").withMessageContaining("SELECT"); } - private static void assertCountQuery(String originalQuery, String countQuery, boolean nativeQuery) { - assertCountQuery(new StringQuery(originalQuery, nativeQuery), countQuery); + @Test // GH-2856 + void jpqlUpdateQueryThrowsExceptionForSorting() { + + DeclaredQuery query = DeclaredQuery.jpqlQuery("UPDATE User SET name = 'test'"); + QueryEnhancer enhancer = QueryEnhancer.create(query); + + Sort sort = Sort.by("id"); + QueryEnhancer.QueryRewriteInformation rewriteInfo = new DefaultQueryRewriteInformation( + sort, ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())); + + assertThatIllegalStateException().isThrownBy(() -> enhancer.rewrite(rewriteInfo)) + .withMessageContaining("Cannot apply sorting to UPDATE statement").withMessageContaining("SELECT"); + } + + @Test // GH-2856 + void jpqlDeleteQueryThrowsExceptionForCountQuery() { + + DeclaredQuery query = DeclaredQuery.jpqlQuery("DELETE FROM User u WHERE u.id = :id"); + QueryEnhancer enhancer = QueryEnhancer.create(query); + + assertThatIllegalStateException().isThrownBy(() -> enhancer.createCountQueryFor(null)) + .withMessageContaining("Cannot derive count query for DELETE statement").withMessageContaining("SELECT"); + } + + @Test // GH-2856 + void jpqlAllowsUnsortedForNonSelectQueries() { + + DeclaredQuery query = DeclaredQuery.jpqlQuery("UPDATE User SET name = 'test'"); + QueryEnhancer enhancer = QueryEnhancer.create(query); + + QueryEnhancer.QueryRewriteInformation rewriteInfo = new DefaultQueryRewriteInformation( + Sort.unsorted(), ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())); + + String result = enhancer.rewrite(rewriteInfo); + assertThat(result).isEqualTo("UPDATE User SET name = 'test'"); } - 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 51fb6d8d37..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 @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. @@ -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> 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 ffbee9e80f..6797362679 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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -15,11 +15,9 @@ */ package org.springframework.data.jpa.repository.query; -import static java.util.Collections.singletonList; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; import jakarta.persistence.Entity; import jakarta.persistence.EntityManager; @@ -34,6 +32,8 @@ 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.Root; import jakarta.persistence.spi.PersistenceProvider; import jakarta.persistence.spi.PersistenceProviderResolver; @@ -45,20 +45,25 @@ import java.util.function.Consumer; import java.util.stream.Collectors; +import org.hibernate.query.sqm.internal.SqmQueryImpl; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mockito; + import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.jpa.domain.sample.Category; import org.springframework.data.jpa.domain.sample.Invoice; import org.springframework.data.jpa.domain.sample.InvoiceItem; import org.springframework.data.jpa.domain.sample.Order; +import org.springframework.data.jpa.domain.sample.ReferencingEmbeddedIdExampleEmployee; +import org.springframework.data.jpa.domain.sample.ReferencingIdClassExampleEmployee; import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.infrastructure.HibernateTestUtils; import org.springframework.data.mapping.PropertyPath; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.transaction.annotation.Transactional; /** * Integration tests for {@link QueryUtils}. @@ -69,6 +74,7 @@ * @author Patrice Blanchardie * @author Diego Krupitza * @author Krzysztof Krason + * @author Jakub Soltys */ @ExtendWith(SpringExtension.class) @ContextConfiguration("classpath:infrastructure.xml") @@ -92,6 +98,43 @@ void reusesExistingJoinForExpression() { assertThat(from.getJoins()).hasSize(1); } + @Test // GH-2756 + void reusesExistingFetchJoinForExpression() { + + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(User.class); + Root from = query.from(User.class); + from.fetch("manager"); + + PropertyPath managerFirstname = PropertyPath.from("manager.firstname", User.class); + PropertyPath managerLastname = PropertyPath.from("manager.lastname", User.class); + + QueryUtils.toExpressionRecursively(from, managerLastname); + QueryUtils.toExpressionRecursively(from, managerFirstname); + + assertThat(from.getFetches()).hasSize(1); + assertThat(from.getJoins()).isEmpty(); + } + + @Test // GH-2756 + void prefersFetchOverJoin() { + + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(User.class); + Root from = query.from(User.class); + from.fetch("manager"); + from.join("manager"); + + PropertyPath managerFirstname = PropertyPath.from("manager.firstname", User.class); + PropertyPath managerLastname = PropertyPath.from("manager.lastname", User.class); + + QueryUtils.toExpressionRecursively(from, managerLastname); + Path expr = (Path) QueryUtils.toExpressionRecursively(from, managerFirstname); + + assertThat(expr.getParentPath()).hasFieldOrPropertyWithValue("fetched", true); + assertThat(from.getFetches()).hasSize(1); + } + @Test // DATAJPA-401, DATAJPA-1238 void createsJoinForNavigationAcrossOptionalAssociation() { @@ -315,6 +358,22 @@ void toOrdersCanSortByJoinColumn() { assertThat(orders).hasSize(1); } + @Test // GH-3529, GH-3587 + void queryUtilsConsidersNullPrecedence() { + + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(User.class); + Root root = query.from(User.class); + Join join = root.join("manager", JoinType.LEFT); + + Sort sort = Sort.by(Sort.Order.desc("manager").nullsFirst()); + + List orders = QueryUtils.toOrders(sort, join, builder); + for (jakarta.persistence.criteria.Order order : orders) { + assertThat(order.getNullPrecedence()).isEqualTo(Nulls.FIRST); + } + } + /** * This test documents an ambiguity in the JPA spec (or it's implementation in Hibernate vs EclipseLink) that we have * to work around in the test {@link #doesNotCreateJoinForOptionalAssociationWithoutFurtherNavigation()}. See also: @@ -332,6 +391,68 @@ void demonstrateDifferentBehaviorOfGetJoin() { assertThat(root.getJoins()).hasSize(getNumberOfJoinsAfterCreatingAPath()); } + @Test // GH-3349 + void doesNotCreateJoinForRelationshipSimpleId() { + + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(User.class); + Root from = query.from(User.class); + + QueryUtils.toExpressionRecursively(from, PropertyPath.from("manager.id", User.class)); + + assertThat(from.getFetches()).isEmpty(); + assertThat(from.getJoins()).isEmpty(); + } + + @Test // GH-3349 + void doesNotCreateJoinForRelationshipEmbeddedId() { + + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(ReferencingEmbeddedIdExampleEmployee.class); + Root from = query.from(ReferencingEmbeddedIdExampleEmployee.class); + + QueryUtils.toExpressionRecursively(from, PropertyPath.from("employee.employeePk.employeeId", ReferencingEmbeddedIdExampleEmployee.class)); + + assertThat(from.getFetches()).isEmpty(); + assertThat(from.getJoins()).isEmpty(); + } + + @Test // GH-3349 + void doesNotCreateJoinForRelationshipIdClass() { + + CriteriaBuilder builder = em.getCriteriaBuilder(); + CriteriaQuery query = builder.createQuery(ReferencingIdClassExampleEmployee.class); + Root from = query.from(ReferencingIdClassExampleEmployee.class); + + QueryUtils.toExpressionRecursively(from, PropertyPath.from("employee.empId", ReferencingIdClassExampleEmployee.class)); + + assertThat(from.getFetches()).isEmpty(); + assertThat(from.getJoins()).isEmpty(); + } + + @Test // GH-3983, GH-2870 + @Transactional + void applyAndBindOptimizesIn() { + + em.getCriteriaBuilder(); + SqmQueryImpl query = (SqmQueryImpl) QueryUtils + .applyAndBind("DELETE FROM User u", List.of(new User(), new User()), em.unwrap(null)); + + assertThat(query.getQueryString()).isEqualTo("DELETE FROM User u where u IN (?1)"); + } + + @Test // GH-3983, GH-2870 + @Transactional + void applyAndBindExpandsToPositionalPlaceholders() { + + em.getCriteriaBuilder(); + SqmQueryImpl query = (SqmQueryImpl) QueryUtils + .applyAndBind("DELETE FROM User u", List.of(new User(), new User()), em.unwrap(null), + org.springframework.data.jpa.provider.PersistenceProvider.ECLIPSELINK); + + assertThat(query.getQueryString()).isEqualTo("DELETE FROM User u where u = ?1 or u = ?2"); + } + int getNumberOfJoinsAfterCreatingAPath() { return 0; } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsUnitTests.java index b2b2c4acd6..717791afec 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -51,6 +51,7 @@ * @author Erik Pellizzon * @author Pranav HS * @author Eduard Dudar + * @author Mark Paluch */ class QueryUtilsUnitTests { @@ -130,13 +131,13 @@ void detectsAliasCorrectly() { .isEqualTo("u"); assertThat(detectAlias( "from Foo f left join f.bar b with type(b) = BarChild where (f.id = (select max(f.id) from Foo f2 where type(f2) = FooChild) or 1 <> 1) and 1=1")) - .isEqualTo("f"); + .isEqualTo("f"); assertThat(detectAlias( "(from Foo f max(f) ((((select * from Foo f2 (from Foo f3) max(*)) (from Foo f4)) max(f5)) (f6)) (from Foo f7))")) - .isEqualTo("f"); + .isEqualTo("f"); assertThat(detectAlias( "SELECT e FROM DbEvent e WHERE (CAST(:modifiedFrom AS date) IS NULL OR e.modificationDate >= :modifiedFrom)")) - .isEqualTo("e"); + .isEqualTo("e"); assertThat(detectAlias("from User u where (cast(:effective as date) is null) OR :effective >= u.createdAt")) .isEqualTo("u"); assertThat(detectAlias("from User u where (cast(:effectiveDate as date) is null) OR :effectiveDate >= u.createdAt")) @@ -145,7 +146,7 @@ void detectsAliasCorrectly() { .isEqualTo("u"); assertThat( detectAlias("from User u where (cast(:e1f2f3ectiveFrom as date) is null) OR :effectiveFrom >= u.createdAt")) - .isEqualTo("u"); + .isEqualTo("u"); } @Test // GH-2260 @@ -175,13 +176,13 @@ void testRemoveSubqueries() throws Exception { .isEqualTo("(select u from User u where not exists )"); assertThat(normalizeWhitespace( removeSubqueries("select u from User u where not exists (from User u2 where not exists (from User u3))"))) - .isEqualTo("select u from User u where not exists"); + .isEqualTo("select u from User u where not exists"); assertThat(normalizeWhitespace( removeSubqueries("select u from User u where not exists ((from User u2 where not exists (from User u3)))"))) - .isEqualTo("select u from User u where not exists ( )"); + .isEqualTo("select u from User u where not exists ( )"); assertThat(normalizeWhitespace( removeSubqueries("(select u from User u where not exists ((from User u2 where not exists (from User u3))))"))) - .isEqualTo("(select u from User u where not exists ( ))"); + .isEqualTo("(select u from User u where not exists ( ))"); } @Test // GH-2581 @@ -543,6 +544,32 @@ void doesNotPrefixAliasedFunctionCallNameWhenQueryStringContainsMultipleWhiteSpa assertThat(applySorting(query, sort, "m")).endsWith("order by avgPrice asc"); } + @Test // GH-3911 + void discoversFunctionAliasesCorrectly() { + + assertThat(getFunctionAliases("SELECT COUNT(1) a alias1,2 s alias2")).isEmpty(); + assertThat(getFunctionAliases("SELECT COUNT(1) as alias1,2 as alias2")).containsExactly("alias1"); + assertThat(getFunctionAliases("SELECT COUNT(1) as alias1,COUNT(2) as alias2")).contains("alias1", "alias2"); + assertThat(getFunctionAliases("SELECT COUNT(1) as alias1, 2 as alias2")).containsExactly("alias1"); + assertThat(getFunctionAliases("SELECT COUNT(1) as alias1, COUNT(2) as alias2")).contains("alias1", "alias2"); + assertThat(getFunctionAliases("COUNT(1) as alias1,COUNT(2) as alias2")).contains("alias1", "alias2"); + assertThat(getFunctionAliases("COUNT(1) as alias1,COUNT(2) as alias2")).contains("alias1", "alias2"); + assertThat(getFunctionAliases("1 as alias1, COUNT(2) as alias2")).containsExactly("alias2"); + assertThat(getFunctionAliases("1 as alias1, COUNT(2) as alias2")).containsExactly("alias2"); + assertThat(getFunctionAliases("COUNT(1) as alias1,2 as alias2")).containsExactly("alias1"); + assertThat(getFunctionAliases("COUNT(1) as alias1, 2 as alias2")).containsExactly("alias1"); + } + + @Test // GH-3911 + void discoversFieldAliasesCorrectly() { + + assertThat(getFieldAliases("SELECT 1 a alias1,2 s alias2")).isEmpty(); + assertThat(getFieldAliases("SELECT 1 as alias1,2 as alias2")).contains("alias1", "alias2"); + assertThat(getFieldAliases("SELECT 1 as alias1,2 as alias2")).contains("alias1", "alias2"); + assertThat(getFieldAliases("1 as alias1,2 as alias2")).contains("alias1", "alias2"); + assertThat(getFieldAliases("1 as alias1, 2 as alias2")).contains("alias1", "alias2"); + } + @Test // DATAJPA-1000 void discoversCorrectAliasForJoinFetch() { @@ -564,7 +591,7 @@ 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 @@ -784,18 +811,19 @@ void applySortingAccountsForNativeWindowFunction() { // order by in over clause + at the end assertThat( QueryUtils.applySorting("select dense_rank() over (order by lastname) from user u order by u.lastname", sort)) - .isEqualTo("select dense_rank() over (order by lastname) from user u order by u.lastname, u.age desc"); + .isEqualTo("select dense_rank() over (order by lastname) from user u order by u.lastname, u.age desc"); // partition by + order by in over clause - assertThat(QueryUtils.applySorting( - "select dense_rank() over (partition by active, age order by lastname) from user u", sort)).isEqualTo( + assertThat(QueryUtils + .applySorting("select dense_rank() over (partition by active, age order by lastname) from user u", sort)) + .isEqualTo( "select dense_rank() over (partition by active, age order by lastname) from user u order by u.age desc"); // partition by + order by in over clause + order by at the end assertThat(QueryUtils.applySorting( "select dense_rank() over (partition by active, age order by lastname) from user u order by active", sort)) - .isEqualTo( - "select dense_rank() over (partition by active, age order by lastname) from user u order by active, u.age desc"); + .isEqualTo( + "select dense_rank() over (partition by active, age order by lastname) from user u order by active, u.age desc"); // partition by + order by in over clause + frame clause assertThat(QueryUtils.applySorting( @@ -812,8 +840,7 @@ void applySortingAccountsForNativeWindowFunction() { // order by in subselect (select expression) assertThat( QueryUtils.applySorting("select lastname, (select i.id from item i order by i.id limit 1) from user u", sort)) - .isEqualTo( - "select lastname, (select i.id from item i order by i.id limit 1) from user u order by u.age desc"); + .isEqualTo("select lastname, (select i.id from item i order by i.id limit 1) from user u order by u.age desc"); // order by in subselect (select expression) + at the end assertThat(QueryUtils.applySorting( @@ -949,7 +976,7 @@ select q.specialist_id, listagg(q.points, '%s') as points @Test // GH-3324 void createCountQueryForSimpleQuery() { - assertCountQuery("select * from User","select count(*) from User"); - assertCountQuery("select * from User u","select count(u) from User u"); + assertCountQuery("select * from User", "select count(*) from User"); + assertCountQuery("select * from User u", "select count(u) from User u"); } } 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 59f418aa61..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 @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. @@ -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/SimilarityNormalizerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/SimilarityNormalizerUnitTests.java new file mode 100644 index 0000000000..37f08ef12b --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/SimilarityNormalizerUnitTests.java @@ -0,0 +1,76 @@ +/* + * 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.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SimilarityNormalizer}. + * + * @author Mark Paluch + */ +class SimilarityNormalizerUnitTests { + + @Test + void normalizesEuclidean() { + + assertThat(SimilarityNormalizer.EUCLIDEAN.getSimilarity(0)).isCloseTo(1.0, offset(0.01)); + assertThat(SimilarityNormalizer.EUCLIDEAN.getSimilarity(0.223606791085977)).isCloseTo(0.9523810148239136, + offset(0.01)); + assertThat(SimilarityNormalizer.EUCLIDEAN.getSimilarity(1.1618950141221271)).isCloseTo(0.42553189396858215, + offset(0.01)); + + assertThat(SimilarityNormalizer.EUCLIDEAN.getScore(1.0)).isCloseTo(0.0, offset(0.01)); + assertThat(SimilarityNormalizer.EUCLIDEAN.getScore(0.9523810148239136)).isCloseTo(0.223606791085977, offset(0.01)); + assertThat(SimilarityNormalizer.EUCLIDEAN.getScore(0.42553189396858215)).isCloseTo(1.1618950141221271, + offset(0.01)); + } + + @Test + void normalizesCosine() { + + assertThat(SimilarityNormalizer.COSINE.getSimilarity(0)).isCloseTo(1.0, offset(0.01)); + assertThat(SimilarityNormalizer.COSINE.getSimilarity(0.004470301418728173)).isCloseTo(0.9977648258209229, + offset(0.01)); + assertThat(SimilarityNormalizer.COSINE.getSimilarity(0.05568200370295473)).isCloseTo(0.9721590280532837, + offset(0.01)); + + assertThat(SimilarityNormalizer.COSINE.getScore(1.0)).isCloseTo(0.0, offset(0.01)); + assertThat(SimilarityNormalizer.COSINE.getScore(0.9977648258209229)).isCloseTo(0.004470301418728173, offset(0.01)); + assertThat(SimilarityNormalizer.COSINE.getScore(0.9721590280532837)).isCloseTo(0.05568200370295473, offset(0.01)); + } + + @Test + void normalizesNegativeInnerProduct() { + + assertThat(SimilarityNormalizer.DOT_PRODUCT.getSimilarity(-0.8465620279312134)).isCloseTo(0.9232810139656067, + offset(0.01)); + assertThat(SimilarityNormalizer.DOT_PRODUCT.getSimilarity(-1.0626180171966553)).isCloseTo(1.0313090085983276, + offset(0.01)); + assertThat(SimilarityNormalizer.DOT_PRODUCT.getSimilarity(-2.0293400287628174)).isCloseTo(1.5146700143814087, + offset(0.01)); + + assertThat(SimilarityNormalizer.DOT_PRODUCT.getScore(0.9232810139656067)).isCloseTo(-0.8465620279312134, + offset(0.01)); + assertThat(SimilarityNormalizer.DOT_PRODUCT.getScore(1.0313090085983276)).isCloseTo(-1.0626180171966553, + offset(0.01)); + assertThat(SimilarityNormalizer.DOT_PRODUCT.getScore(1.5146700143814087)).isCloseTo(-2.0293400287628174, + offset(0.01)); + } + +} 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 558dc747e3..dec4b7b203 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -22,13 +22,16 @@ import jakarta.persistence.EntityManagerFactory; import jakarta.persistence.Tuple; import jakarta.persistence.TypedQuery; +import jakarta.persistence.metamodel.EntityType; import jakarta.persistence.metamodel.Metamodel; import java.lang.reflect.Method; import java.util.Collection; import java.util.List; import java.util.Optional; +import java.util.Set; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,19 +45,22 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.sample.Country; import org.springframework.data.jpa.domain.sample.User; 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; +import org.springframework.data.repository.Repository; import org.springframework.data.repository.core.RepositoryMetadata; -import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; +import org.springframework.data.repository.query.Param; +import org.springframework.data.repository.query.QueryCreationException; import org.springframework.data.repository.query.RepositoryQuery; -import org.springframework.data.util.TypeInformation; -import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.lang.Nullable; +import org.springframework.data.repository.query.ResultProcessor; +import org.springframework.data.repository.query.ValueExpressionDelegate; /** * Unit test for {@link SimpleJpaQuery}. @@ -68,14 +74,16 @@ * @author Krzysztof Krason * @author Erik Pellizzon * @author Christoph Strobl + * @author Danny van den Elshout */ @ExtendWith(MockitoExtension.class) @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 static final SpelExpressionParser PARSER = new SpelExpressionParser(); - private static final QueryMethodEvaluationContextProvider EVALUATION_CONTEXT_PROVIDER = QueryMethodEvaluationContextProvider.DEFAULT; private JpaQueryMethod method; @@ -84,14 +92,13 @@ class SimpleJpaQueryUnitTests { @Mock QueryExtractor extractor; @Mock jakarta.persistence.Query query; @Mock TypedQuery typedQuery; - @Mock RepositoryMetadata metadata; + RepositoryMetadata metadata; @Mock ParameterBinder binder; @Mock Metamodel metamodel; private ProjectionFactory factory = new SpelAwareProxyProjectionFactory(); @BeforeEach - @SuppressWarnings({ "rawtypes", "unchecked" }) void setUp() throws SecurityException, NoSuchMethodException { when(em.getMetamodel()).thenReturn(metamodel); @@ -100,12 +107,8 @@ void setUp() throws SecurityException, NoSuchMethodException { when(em.getEntityManagerFactory()).thenReturn(emf); when(em.getDelegate()).thenReturn(em); when(emf.createEntityManager()).thenReturn(em); - when(metadata.getRepositoryInterface()).thenReturn((Class) SampleRepository.class); - when(metadata.getDomainType()).thenReturn((Class) User.class); - when(metadata.getDomainTypeInformation()).thenReturn((TypeInformation) TypeInformation.of(User.class)); - when(metadata.getReturnedDomainClass(Mockito.any(Method.class))).thenReturn((Class) User.class); - when(metadata.getReturnType(Mockito.any(Method.class))) - .thenAnswer(invocation -> TypeInformation.fromReturnTypeOf(invocation.getArgument(0))); + + metadata = AbstractRepositoryMetadata.getMetadata(SampleRepository.class); Method setUp = UserRepository.class.getMethod("findByLastname", String.class); method = new JpaQueryMethod(setUp, metadata, factory, extractor); @@ -119,8 +122,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, EVALUATION_CONTEXT_PROVIDER, PARSER); + 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 +137,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, EVALUATION_CONTEXT_PROVIDER, PARSER); + 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) })); @@ -144,34 +147,40 @@ void doesNotApplyPaginationToCountQuery() throws Exception { } @Test - @SuppressWarnings({ "rawtypes", "unchecked" }) 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, - EVALUATION_CONTEXT_PROVIDER); + AbstractJpaQuery jpaQuery = JpaQueryLookupStrategy.DeclaredQueryLookupStrategy.createStringQuery(queryMethod, em, + queryMethod.getRequiredDeclaredQuery(), null, CONFIG); assertThat(jpaQuery).isInstanceOf(NativeJpaQuery.class); when(em.createNativeQuery(anyString(), eq(User.class))).thenReturn(query); - when(metadata.getReturnedDomainClass(method)).thenReturn((Class) User.class); jpaQuery.createQuery(new JpaParametersParameterAccessor(queryMethod.getParameters(), new Object[] { "Matthews" })); verify(em).createNativeQuery("SELECT u FROM User u WHERE u.lastname = ?1", User.class); } - @Test // DATAJPA-554 - void rejectsNativeQueryWithDynamicSort() throws Exception { + @Test // GH-3155 + void discoversNativeQueryFromNativeQueryInterface() throws Exception { + + Method method = SampleRepository.class.getMethod("findByLastnameNativeAnnotation", String.class); + JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor); + AbstractJpaQuery jpaQuery = JpaQueryLookupStrategy.DeclaredQueryLookupStrategy.createStringQuery(queryMethod, em, + queryMethod.getRequiredDeclaredQuery(), null, CONFIG); - Method method = SampleRepository.class.getMethod("findNativeByLastname", String.class, Sort.class); - assertThatExceptionOfType(InvalidJpaQueryMethodException.class).isThrownBy(() -> createJpaQuery(method)); + assertThat(jpaQuery).isInstanceOf(NativeJpaQuery.class); + + when(em.createNativeQuery(anyString(), eq(User.class))).thenReturn(query); + + jpaQuery.createQuery(new JpaParametersParameterAccessor(queryMethod.getParameters(), new Object[] { "Matthews" })); + + verify(em).createNativeQuery("SELECT u FROM User u WHERE u.lastname = ?1", User.class); } @Test // DATAJPA-352 - @SuppressWarnings("unchecked") void doesNotValidateCountQueryIfNotPagingMethod() throws Exception { Method method = SampleRepository.class.getMethod("findByAnnotatedQuery"); @@ -180,16 +189,16 @@ void doesNotValidateCountQueryIfNotPagingMethod() throws Exception { createJpaQuery(method); } - @Test // DATAJPA-352 - @SuppressWarnings("unchecked") + @Test // DATAJPA-352, GH-2736 void validatesAndRejectsCountQueryIfPagingMethod() throws Exception { Method method = SampleRepository.class.getMethod("pageByAnnotatedQuery", Pageable.class); when(em.createQuery(Mockito.contains("count"))).thenThrow(IllegalArgumentException.class); - assertThatIllegalArgumentException().isThrownBy(() -> createJpaQuery(method)).withMessageContaining("Count") - .withMessageContaining(method.getName()); + assertThatExceptionOfType(QueryCreationException.class) // + .isThrownBy(() -> createJpaQuery(method)) // + .withMessageContaining("User u"); } @Test @@ -226,10 +235,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 queryStringCaptor = ArgumentCaptor.forClass(String.class); verify(em).createQuery(queryStringCaptor.capture(), eq(Long.class)); @@ -250,6 +260,57 @@ void projectsWithManuallyDeclaredQuery() throws Exception { verify(em, times(2)).createQuery(anyString()); } + @Test // GH-3895 + void doesNotRewriteQueryReturningEntity() throws Exception { + + EntityType entityType = mock(EntityType.class); + when(entityType.getJavaType()).thenReturn((Class) UnrelatedType.class); + when(metamodel.getManagedTypes()).thenReturn(Set.of(entityType)); + + AbstractStringBasedJpaQuery jpaQuery = (AbstractStringBasedJpaQuery) createJpaQuery( + SampleRepository.class.getMethod("selectWithJoin")); + + String queryString = createQuery(jpaQuery); + + assertThat(queryString).startsWith("SELECT cd FROM CampaignDeal cd"); + } + + @Test // GH-3895 + void rewriteQueryReturningDto() throws Exception { + + AbstractStringBasedJpaQuery jpaQuery = (AbstractStringBasedJpaQuery) createJpaQuery( + SampleRepository.class.getMethod("selectWithJoin")); + + String queryString = createQuery(jpaQuery); + + assertThat(queryString).startsWith( + "SELECT new org.springframework.data.jpa.repository.query.SimpleJpaQueryUnitTests$UnrelatedType(cd.name)"); + } + + @Test // GH-3895 + void rewritesQueryForUnknownProperty() throws Exception { + + AbstractStringBasedJpaQuery jpaQuery = (AbstractStringBasedJpaQuery) createJpaQuery( + SampleRepository.class.getMethod("projectWithUnknownPaths")); + + String queryString = createQuery(jpaQuery); + + assertThat(queryString).startsWith( + "select new org.springframework.data.jpa.repository.query.SimpleJpaQueryUnitTests$UnrelatedType(u.unknown)"); + } + + @Test // GH-3895 + void rewritesQueryForJoinPath() throws Exception { + + AbstractStringBasedJpaQuery jpaQuery = (AbstractStringBasedJpaQuery) createJpaQuery( + SampleRepository.class.getMethod("projectWithJoinPaths")); + + String queryString = createQuery(jpaQuery); + + assertThat(queryString).startsWith( + "select new org.springframework.data.jpa.repository.query.SimpleJpaQueryUnitTests$UnrelatedType(r.name) from User u LEFT JOIN FETCH u.roles r"); + } + @Test // DATAJPA-1307 void jdbcStyleParametersOnlyAllowedInNativeQueries() throws Exception { @@ -258,7 +319,18 @@ void jdbcStyleParametersOnlyAllowedInNativeQueries() throws Exception { Method illegalMethod = SampleRepository.class.getMethod("illegalUseOfJdbcStyleParameters", String.class); - assertThatIllegalArgumentException().isThrownBy(() -> createJpaQuery(illegalMethod)); + assertThatExceptionOfType(QueryCreationException.class).isThrownBy(() -> createJpaQuery(illegalMethod)); + } + + @Test // GH-3929 + void doesNotRewriteQueryForDtoWithMultipleConstructors() throws Exception { + + AbstractStringBasedJpaQuery jpaQuery = (AbstractStringBasedJpaQuery) createJpaQuery( + SampleRepository.class.getMethod("justCountries")); + + String queryString = createQuery(jpaQuery); + + assertThat(queryString).startsWith("select u.country from User u"); } @Test // DATAJPA-1163 @@ -269,9 +341,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, - EVALUATION_CONTEXT_PROVIDER, PARSER); + AbstractJpaQuery jpaQuery = new SimpleJpaQuery(queryMethod, em, + queryMethod.getDeclaredQuery("select u from User /*some comment*/ u"), + queryMethod.getDeclaredQuery("select count(u.id) from #{#entityName} u"), CONFIG); jpaQuery.createCountQuery( new JpaParametersParameterAccessor(queryMethod.getParameters(), new Object[] { PageRequest.of(1, 10) })); @@ -283,25 +355,34 @@ 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, EVALUATION_CONTEXT_PROVIDER); + return JpaQueryLookupStrategy.DeclaredQueryLookupStrategy.createStringQuery(queryMethod, em, query, countQzery, + CONFIG); } - private AbstractJpaQuery createJpaQuery(Method method, @Nullable Optional countQueryString) { + private AbstractJpaQuery createJpaQuery(Method method, @Nullable Optional 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 { + private String createQuery(AbstractStringBasedJpaQuery jpaQuery) { + JpaParametersParameterAccessor accessor = new JpaParametersParameterAccessor( + jpaQuery.getQueryMethod().getParameters(), new Object[0]); + ResultProcessor processor = jpaQuery.getQueryMethod().getResultProcessor().withDynamicProjection(accessor); + return jpaQuery.getSortedQuery(Sort.unsorted(), jpaQuery.getReturnedType(processor)).getQueryString(); + } + + interface SampleRepository extends Repository { @Query(value = "SELECT u FROM User u WHERE u.lastname = ?1", nativeQuery = true) List findNativeByLastname(String lastname); - @Query(value = "SELECT u FROM User u WHERE u.lastname = ?1", nativeQuery = true) - List findNativeByLastname(String lastname, Sort sort); + @NativeQuery(value = "SELECT u FROM User u WHERE u.lastname = ?1") + List findByLastnameNativeAnnotation(String lastname); @Query(value = "SELECT u FROM User u WHERE u.lastname = ?1", nativeQuery = true) List findNativeByLastname(String lastname, Pageable pageable); @@ -321,14 +402,40 @@ interface SampleRepository { @Query("select u from User u") Collection projectWithExplicitQuery(); + @Query(""" + SELECT cd FROM CampaignDeal cd + LEFT JOIN FETCH cd.dealLibrary d + LEFT JOIN FETCH d.publisher p + WHERE cd.campaignId = :campaignId + """) + Collection selectWithJoin(); + + @Query("select u.unknown from User u") + Collection projectWithUnknownPaths(); + + @Query("select r.name from User u LEFT JOIN FETCH u.roles r") + Collection projectWithJoinPaths(); + + @Query("select u.country from User u") + Collection justCountries(); + @Query(value = "select u from #{#entityName} u", countQuery = "select count(u.id) from #{#entityName} u") List 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 findAllWithBindingsOnlyInCountQuery(String arg0, Pageable pageable); + // Typo in named parameter + @Query("select u from User u where u.firstname = :foo") + List findByAnnotatedQuery(@Param("param") String param); } interface UserProjection {} + + static class UnrelatedType { + + public UnrelatedType(String name) {} + + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributeSourceUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributeSourceUnitTests.java index bcfc8bf9ad..bb0568cb28 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributeSourceUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributeSourceUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -15,9 +15,8 @@ */ package org.springframework.data.jpa.repository.query; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; import jakarta.persistence.EntityManager; import jakarta.persistence.Id; @@ -42,6 +41,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; + import org.springframework.core.annotation.AliasFor; import org.springframework.data.jpa.domain.sample.Dummy; import org.springframework.data.jpa.domain.sample.User; @@ -74,7 +74,7 @@ void setup() { creator = StoredProcedureAttributeSource.INSTANCE; doReturn(User.class).when(entityMetadata).getJavaType(); - when(entityMetadata.getEntityName()).thenReturn("User"); + when(entityMetadata.getEntityName()).thenReturn("Some$User"); } @Test // DATAJPA-455 diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributesUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributesUnitTests.java index 919f12d1a9..b9c495778d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributesUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributesUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. 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 parametersList = new ArrayList<>(parameterTypes.length); + List 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 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 74% 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 e92c16eb88..b31be6de60 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 @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -27,12 +27,13 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; + 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; -import org.springframework.expression.spel.standard.SpelExpressionParser; /** - * Unit tests for {@link ExpressionBasedStringQuery}. + * Unit tests for {@link TemplatedQuery}. * * @author Thomas Darimont * @author Oliver Gierke @@ -44,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 SpelExpressionParser SPEL_PARSER = new SpelExpressionParser(); @Mock JpaEntityMetadata metadata; @BeforeEach @@ -58,27 +61,34 @@ void setUp() { void shouldReturnQueryWithDomainTypeExpressionReplacedWithSimpleDomainTypeName() { String source = "select u from #{#entityName} u where u.firstname like :firstname"; - StringQuery query = new ExpressionBasedStringQuery(source, metadata, SPEL_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, SPEL_PARSER, true); + DefaultEntityQuery query = jpqlEntityQuery("select u from #{#entityName} u"); assertThat(query.getAlias()).isEqualTo("u"); assertThat(query.getQueryString()).isEqualTo("select u from User u"); } + @Test // GH-3979 + void renderAliasInNativeExpressionQueryCorrectly() { + + DefaultEntityQuery query = nativeEntityQuery("select u.* from #{#entityName} u"); + assertThat(query.getAlias()).isEqualTo("u"); + assertThat(query.getQueryString()).isEqualTo("select u.* from User u"); + } + @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, SPEL_PARSER, false); + + "AND (n.updatedAt >= :#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=:#{#networkRequest.updatedTime.endDateTime})"); assertThat(query.getParameterBindings()).hasSize(8); } @@ -86,12 +96,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, SPEL_PARSER, false); + + "AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})"); assertThat(query.getParameterBindings()).hasSize(8); } @@ -99,38 +108,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, SPEL_PARSER, true); + + "AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})"); - assertThat(query.isNativeQuery()).isFalse(); - } - - @Test - void shouldDetectSimpleNativeQueriesWithSpelAsNonNative() { - - StringQuery query = new ExpressionBasedStringQuery("select n from #{#entityName} n", metadata, SPEL_PARSER, true); - - assertThat(query.isNativeQuery()).isFalse(); + assertThat(query.isNative()).isFalse(); } @Test void shouldDetectSimpleNativeQueriesWithoutSpelAsNative() { - StringQuery query = new ExpressionBasedStringQuery("select u from User u", metadata, SPEL_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, SPEL_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( @@ -153,9 +152,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, SPEL_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()) @@ -176,29 +174,34 @@ void indexedExpressionsShouldCreateLikeBindings() { } @Test - public void doesTemplatingWhenEntityNameSpelIsPresent() { + void doesTemplatingWhenEntityNameSpelIsPresent() { - StringQuery query = new ExpressionBasedStringQuery("select #{#entityName + 'Hallo'} from #{#entityName} u", - metadata, SPEL_PARSER, false); + EntityQuery query = jpqlEntityQuery("select #{#entityName + 'Hallo'} from #{#entityName} u"); assertThat(query.getQueryString()).isEqualTo("select UserHallo from User u"); } @Test - public void doesNoTemplatingWhenEntityNameSpelIsNotPresent() { + void doesNoTemplatingWhenEntityNameSpelIsNotPresent() { - StringQuery query = new ExpressionBasedStringQuery("select #{#entityName + 'Hallo'} from User u", metadata, - SPEL_PARSER, false); + EntityQuery query = jpqlEntityQuery("select #{#entityName + 'Hallo'} from User u"); assertThat(query.getQueryString()).isEqualTo("select UserHallo from User u"); } @Test - public void doesTemplatingWhenEntityNameSpelIsPresentForBindParameter() { + void doesTemplatingWhenEntityNameSpelIsPresentForBindParameter() { - StringQuery query = new ExpressionBasedStringQuery("select u from #{#entityName} u where name = :#{#something}", - metadata, SPEL_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/query/TupleConverterUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TupleConverterUnitTests.java index 13dc550fb0..3321c2584d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TupleConverterUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TupleConverterUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. @@ -18,14 +18,14 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; +import jakarta.persistence.Tuple; +import jakarta.persistence.TupleElement; + import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Map; -import jakarta.persistence.Tuple; -import jakarta.persistence.TupleElement; - import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -37,6 +37,7 @@ import org.springframework.data.jpa.repository.query.AbstractJpaQuery.TupleConverter; import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; @@ -48,6 +49,8 @@ * * @author Oliver Gierke * @author Jens Schauder + * @author Christoph Strobl + * @author Mark Paluch * @soundtrack James Bay - Let it go (Chaos and the Calm) */ @ExtendWith(MockitoExtension.class) @@ -113,6 +116,91 @@ void findsValuesForAllVariantsSupportedByTheTuple() { softly.assertAll(); } + @Test // GH-3076 + void dealsWithNullsInArguments() { + + ReturnedType returnedType = ReturnedType.of(WithPC.class, DomainType.class, new SpelAwareProxyProjectionFactory()); + + doReturn(List.of(element, element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn("one"); + when(tuple.get(eq(1))).thenReturn(null); + when(tuple.get(eq(2))).thenReturn(1); + + Object result = new TupleConverter(returnedType).convert(tuple); + assertThat(result).isInstanceOf(WithPC.class); + } + + @Test // GH-3076 + void fallsBackToCompatibleConstructor() { + + ReturnedType returnedType = spy( + ReturnedType.of(MultipleConstructors.class, DomainType.class, new SpelAwareProxyProjectionFactory())); + when(returnedType.isProjecting()).thenReturn(true); + when(returnedType.getInputProperties()).thenReturn(Arrays.asList("one", "two", "three")); + + doReturn(List.of(element, element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn("one"); + when(tuple.get(eq(1))).thenReturn(null); + when(tuple.get(eq(2))).thenReturn(2); + + MultipleConstructors result = (MultipleConstructors) new TupleConverter(returnedType).convert(tuple); + + assertThat(result.one).isEqualTo("one"); + assertThat(result.two).isNull(); + assertThat(result.three).isEqualTo(2); + + reset(tuple); + + doReturn(List.of(element, element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn("one"); + when(tuple.get(eq(1))).thenReturn(null); + when(tuple.get(eq(2))).thenReturn('a'); + + result = (MultipleConstructors) new TupleConverter(returnedType).convert(tuple); + + assertThat(result.one).isEqualTo("one"); + assertThat(result.two).isNull(); + assertThat(result.three).isEqualTo(97); + } + + @Test // GH-3076 + void acceptsConstructorWithCastableType() { + + ReturnedType returnedType = spy( + ReturnedType.of(MultipleConstructors.class, DomainType.class, new SpelAwareProxyProjectionFactory())); + when(returnedType.isProjecting()).thenReturn(true); + when(returnedType.getInputProperties()).thenReturn(Arrays.asList("one", "two", "three", "four")); + + doReturn(List.of(element, element, element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn("one"); + when(tuple.get(eq(1))).thenReturn(null); + when(tuple.get(eq(2))).thenReturn((byte) 2); + when(tuple.get(eq(3))).thenReturn(2.1f); + + MultipleConstructors result = (MultipleConstructors) new TupleConverter(returnedType).convert(tuple); + + assertThat(result.one).isEqualTo("one"); + assertThat(result.two).isNull(); + assertThat(result.three).isEqualTo(2); + assertThat(result.four).isEqualTo(2, offset(0.1d)); + } + + @Test // GH-3076 + void failsForNonResolvableConstructor() { + + ReturnedType returnedType = spy( + ReturnedType.of(MultipleConstructors.class, DomainType.class, new SpelAwareProxyProjectionFactory())); + when(returnedType.isProjecting()).thenReturn(true); + when(returnedType.getInputProperties()).thenReturn(Arrays.asList("one", "two")); + + doReturn(List.of(element, element)).when(tuple).getElements(); + when(tuple.get(eq(0))).thenReturn(1); + when(tuple.get(eq(1))).thenReturn(null); + + assertThatIllegalStateException().isThrownBy(() -> new TupleConverter(returnedType).convert(tuple)) + .withMessageContaining("Cannot find compatible constructor for DTO projection"); + } + interface SampleRepository extends CrudRepository { String someMethod(); } @@ -177,4 +265,45 @@ public String getAlias() { } } } + + static class DomainType { + String one, two, three; + } + + static class WithPC { + String one; + String two; + long three; + + public WithPC(String one, String two, long three) { + this.one = one; + this.two = two; + this.three = three; + } + } + + static class MultipleConstructors { + String one; + String two; + long three; + double four; + + public MultipleConstructors(String one) { + this.one = one; + } + + public MultipleConstructors(String one, String two, long three) { + this.one = one; + this.two = two; + this.three = three; + } + + public MultipleConstructors(String one, String two, short three, double four) { + this.one = one; + this.two = two; + this.three = three; + this.four = four; + } + + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AnnotatedAuditableUserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AnnotatedAuditableUserRepository.java index 36748415e5..cf549ad521 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AnnotatedAuditableUserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AnnotatedAuditableUserRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AuditableEntityRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AuditableEntityRepository.java index 321fdbf179..216d448d84 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AuditableEntityRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AuditableEntityRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AuditableUserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AuditableUserRepository.java index 127c237fbf..b3d39afc9b 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AuditableUserRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/AuditableUserRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/BookRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/BookRepository.java index dd4af2f2d6..c5a89ebf0f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/BookRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/BookRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/CategoryRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/CategoryRepository.java index 601c4647ae..676a095567 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/CategoryRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/CategoryRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ClassWithNestedRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ClassWithNestedRepository.java index 303910c50b..17089b7e60 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ClassWithNestedRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ClassWithNestedRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ConcreteRepository1.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ConcreteRepository1.java index 52ff1ee123..78ee0efe1d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ConcreteRepository1.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ConcreteRepository1.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ConcreteRepository2.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ConcreteRepository2.java index 5137c87da7..55fef12d72 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ConcreteRepository2.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ConcreteRepository2.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/CustomAbstractPersistableRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/CustomAbstractPersistableRepository.java index 12dcbeed5e..d59a163db7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/CustomAbstractPersistableRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/CustomAbstractPersistableRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. 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/sample/CustomerRepository.java similarity index 51% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaRepositoryWithCompositeKeyIntegrationTests.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/CustomerRepository.java index e3c8eeb334..7a607fc655 100644 --- 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/sample/CustomerRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 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,20 @@ * 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.sample; -import org.springframework.context.annotation.ImportResource; -import org.springframework.test.context.ContextConfiguration; +import java.util.List; + +import org.springframework.data.jpa.domain.sample.Country; +import org.springframework.data.jpa.domain.sample.Customer; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; /** - * 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 { +public interface CustomerRepository extends JpaRepository { - } + @Query("SELECT c.country FROM Customer c") + List findCountries(); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/DummyRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/DummyRepository.java index 07a00c4424..34aef70172 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/DummyRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/DummyRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EmployeeRepositoryWithEmbeddedId.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EmployeeRepositoryWithEmbeddedId.java index c52e42343a..7e8dce12bf 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EmployeeRepositoryWithEmbeddedId.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EmployeeRepositoryWithEmbeddedId.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -21,6 +21,7 @@ import org.springframework.data.jpa.domain.sample.EmbeddedIdExampleEmployee; import org.springframework.data.jpa.domain.sample.EmbeddedIdExampleEmployeePK; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.querydsl.QuerydslPredicateExecutor; import com.querydsl.core.types.OrderSpecifier; @@ -40,6 +41,9 @@ public interface EmployeeRepositoryWithEmbeddedId @Override List findAll(Predicate predicate, OrderSpecifier... orders); + @Query("select e.employeePk from EmbeddedIdExampleEmployee e") + List findIdentifiers(); + // DATAJPA-920 boolean existsByName(String name); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EmployeeRepositoryWithIdClass.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EmployeeRepositoryWithIdClass.java index 703501f0d6..644cc00ded 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EmployeeRepositoryWithIdClass.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EmployeeRepositoryWithIdClass.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -41,4 +41,7 @@ public interface EmployeeRepositoryWithIdClass extends JpaRepository findAllByDepartment_DepartmentId(long departmentId); + List findAllByEmpId(long empId); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EntityWithAssignedIdRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EntityWithAssignedIdRepository.java index 505c658d44..fc6f4714bc 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EntityWithAssignedIdRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EntityWithAssignedIdRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2019-2024 the original author or authors. + * Copyright 2019-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemRepository.java index 9335ce52d9..a82370752c 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemSiteRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemSiteRepository.java index 0151618d4b..dd6ba72176 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemSiteRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ItemSiteRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/MailMessageRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/MailMessageRepository.java index 11ecbd0d61..62ad9525e4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/MailMessageRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/MailMessageRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/MappedTypeRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/MappedTypeRepository.java index 12c1041ef4..a2ce8ab9b1 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/MappedTypeRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/MappedTypeRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. 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 f27d201137..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 @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. @@ -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/NameOnlyRecord.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyRecord.java new file mode 100644 index 0000000000..b72c448345 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyRecord.java @@ -0,0 +1,20 @@ +/* + * Copyright 2018-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.sample; + +public record NameOnlyRecord(String firstname, String lastname) { + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ParentRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ParentRepository.java index 4ab5675004..71f665c311 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ParentRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ParentRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ProductRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ProductRepository.java index 3d34f051ea..2de721721e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ProductRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ProductRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RedeclaringRepositoryMethodsRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RedeclaringRepositoryMethodsRepository.java index dd9343fff3..a5012f6d2b 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RedeclaringRepositoryMethodsRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RedeclaringRepositoryMethodsRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ReferencingEmployeeRepositoryWithEmbeddedIdRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ReferencingEmployeeRepositoryWithEmbeddedIdRepository.java new file mode 100644 index 0000000000..3945a78447 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ReferencingEmployeeRepositoryWithEmbeddedIdRepository.java @@ -0,0 +1,35 @@ +/* + * 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.sample; + +import org.springframework.context.annotation.Lazy; +import org.springframework.data.jpa.domain.sample.ReferencingEmbeddedIdExampleEmployee; +import org.springframework.data.jpa.domain.sample.ReferencingIdClassExampleEmployee; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * Demonstrates the support for composite primary keys with {@code @IdClass}. + * + * @author Jakub Soltys + */ +@Lazy +public interface ReferencingEmployeeRepositoryWithEmbeddedIdRepository extends JpaRepository { + + List findByEmployee_EmployeePk_employeeId(Long employeeId); + List findByEmployee_EmployeePk_DepartmentId(Long departementId); +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ReferencingEmployeeRepositoryWithIdClassRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ReferencingEmployeeRepositoryWithIdClassRepository.java new file mode 100644 index 0000000000..d8ab661b82 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/ReferencingEmployeeRepositoryWithIdClassRepository.java @@ -0,0 +1,34 @@ +/* + * 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.sample; + +import org.springframework.context.annotation.Lazy; +import org.springframework.data.jpa.domain.sample.ReferencingIdClassExampleEmployee; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +/** + * Demonstrates the support for composite primary keys with {@code @IdClass}. + * + * @author Jakub Soltys + */ +@Lazy +public interface ReferencingEmployeeRepositoryWithIdClassRepository extends JpaRepository { + + List findByEmployee_EmpId(Long employeeId); + List findByEmployee_Department_DepartmentId(Long departementId); +} 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 eee395794b..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 @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -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/RoleRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepository.java index 1895ce863a..083e6f03fb 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepositoryWithMeta.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepositoryWithMeta.java index 9258d62457..2fc75192e6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepositoryWithMeta.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RoleRepositoryWithMeta.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SampleConfig.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SampleConfig.java index 7f9ac2aab4..a319360651 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SampleConfig.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SampleConfig.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SampleEvaluationContextExtension.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SampleEvaluationContextExtension.java index 274ba7e601..a1deba7c24 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SampleEvaluationContextExtension.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SampleEvaluationContextExtension.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SiteRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SiteRepository.java index 3bbd36c434..ff5ae98e4c 100755 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SiteRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/SiteRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2024 the original author or authors. + * 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. 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 684b49bced..d51b89202c 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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -18,6 +18,8 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.QueryHint; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; import java.util.Collection; import java.util.Date; import java.util.List; @@ -26,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; @@ -35,12 +39,14 @@ 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; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; 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.Procedure; @@ -49,6 +55,8 @@ import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; +import com.querydsl.core.types.Predicate; + /** * Repository interface for {@code User}s. * @@ -74,6 +82,8 @@ public interface UserRepository extends JpaRepository, JpaSpecifi @QueryHints({ @QueryHint(name = "foo", value = "bar") }) List findByLastname(String lastname); + List findUserByLastname(@Nullable String lastname); + /** * Redeclaration of {@link CrudRepository#findById(java.lang.Object)} to change transaction configuration. */ @@ -82,9 +92,8 @@ public interface UserRepository extends JpaRepository, JpaSpecifi java.util.Optional findById(Integer primaryKey); /** - * Redeclaration of {@link CrudRepository#deleteById(java.lang.Object)}. to make sure the transaction - * configuration of the original method is considered if the redeclaration does not carry a {@link Transactional} - * annotation. + * Redeclaration of {@link CrudRepository#deleteById(java.lang.Object)}. to make sure the transaction configuration of + * the original method is considered if the redeclaration does not carry a {@link Transactional} annotation. */ @Override void deleteById(Integer id); // DATACMNS-649 @@ -176,7 +185,7 @@ Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(S List findByLastnameNotLike(String lastname); - List findByLastnameNot(String lastname); + List findByLastnameNot(@Nullable String lastname); List findByManagerLastname(String name); @@ -188,6 +197,8 @@ Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(S List findByEmailAddressLike(String email, Sort sort); + List findByEmailAddressLike(String email, Pageable pageable); + List findSpecialUsersByLastname(String lastname); List findBySpringDataNamedQuery(String lastname); @@ -288,15 +299,16 @@ Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(S // DATAJPA-460 Long removeByLastname(String lastname); + long removeOneByLastname(String lastname); + + int removeOneMoreByLastname(String lastname); + // DATAJPA-460 List deleteByLastname(String lastname); - /** - * @see OPENJPA-2484 - */ - // DATAJPA-505 - // @Query(value = "select u.binaryData from User u where u.id = :id") - // byte[] findBinaryDataByIdJpaQl(@Param("id") Integer id); + User deleteOneByLastname(String lastname); + + Optional deleteOneOptionalByLastname(String lastname); /** * Explicitly mapped to a procedure with name "plus1inout" in database. @@ -405,7 +417,7 @@ Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(S Slice findTop2UsersBy(Pageable page); // DATAJPA-506 - @Query(value = "select u.binaryData from SD_User u where u.id = ?1", nativeQuery = true) + @NativeQuery("select u.binaryData from SD_User u where u.id = ?1") byte[] findBinaryDataByIdNative(Integer id); // DATAJPA-506 @@ -416,7 +428,8 @@ Window findTop3ByFirstnameStartingWithOrderByFirstnameAscEmailAddressAsc(S @Query("select u from User u where u.firstname = ?#{[0]} and u.firstname = ?1 and u.lastname like %?#{[1]}% and u.lastname like %?2%") List findByFirstnameAndLastnameWithSpelExpression(String firstname, String lastname); - @Query(value = "select * from SD_User", countQuery = "select count(1) from SD_User u where u.lastname = :#{#lastname}", nativeQuery = true) + @Query(value = "select * from SD_User", + countQuery = "select count(1) from SD_User u where u.lastname = :#{#lastname}", nativeQuery = true) Page findByWithSpelParameterOnlyUsedForCountQuery(String lastname, Pageable page); // DATAJPA-564 @@ -538,7 +551,7 @@ List findUsersByFirstnameForSpELExpressionWithParameterIndexOnlyWithEntity List findRolesAndFirstnameBy(); - @Query(value = "FROM User u") + @Query(value = "SELECT u FROM User u") List findIdOnly(); // DATAJPA-1172 @@ -555,34 +568,38 @@ List findUsersByFirstnameForSpELExpressionWithParameterIndexOnlyWithEntity @Query(value = "SELECT firstname, lastname FROM SD_User WHERE id = ?1", nativeQuery = true) NameOnly findByNativeQuery(Integer id); - // DATAJPA-1248 - @Query(value = "SELECT emailaddress, secondary_email_address FROM SD_User WHERE id = ?1", nativeQuery = true) + @NativeQuery("SELECT firstname, lastname FROM SD_User WHERE id = ?1") + NameOnlyRecord findRecordProjectionByNativeQuery(Integer id); + + // GH-3155 + @NativeQuery(value = "SELECT emailaddress, secondary_email_address FROM SD_User WHERE id = ?1", + sqlResultSetMapping = "emailDto") + User.EmailDto findEmailDtoByNativeQuery(Integer id); + + @NativeQuery(value = "SELECT emailaddress, secondary_email_address FROM SD_User WHERE id = ?1") EmailOnly findEmailOnlyByNativeQuery(Integer id); // DATAJPA-1235 @Query("SELECT u FROM User u where u.firstname >= ?1 and u.lastname = '000:1'") List queryWithIndexedParameterAndColonFollowedByIntegerInString(String firstname); - /** - * TODO: ORDER BY CASE appears to only with Hibernate. The examples attempting to do this through pure JPQL don't - * appear to work with Hibernate, so we must set them aside until we can implement HQL. - */ - // // DATAJPA-1233 - // @Query(value = "SELECT u FROM User u ORDER BY CASE WHEN (u.firstname >= :name) THEN 0 ELSE 1 END, u.firstname") - // Page findAllOrderedBySpecialNameSingleParam(@Param("name") String name, Pageable page); - // - // // DATAJPA-1233 - // @Query( - // value = "SELECT u FROM User u WHERE :other = 'x' ORDER BY CASE WHEN (u.firstname >= :name) THEN 0 ELSE 1 END, - // u.firstname") - // Page findAllOrderedBySpecialNameMultipleParams(@Param("name") String name, @Param("other") String other, - // Pageable page); - // - // // DATAJPA-1233 - // @Query( - // value = "SELECT u FROM User u WHERE ?2 = 'x' ORDER BY CASE WHEN (u.firstname >= ?1) THEN 0 ELSE 1 END, - // u.firstname") - // Page findAllOrderedBySpecialNameMultipleParamsIndexed(String other, String name, Pageable page); + @Query(value = "SELECT u FROM User u ORDER BY CASE WHEN (u.firstname >= :name) THEN 0 ELSE 1 END, u.firstname") + Page findAllOrderedByNamedParam(@Param("name") String name, Pageable page); + + @Query(value = "SELECT u FROM User u ORDER BY CASE WHEN (u.firstname >= ?1) THEN 0 ELSE 1 END, u.firstname") + Page findAllOrderedByIndexedParam(String name, Pageable page); + + @Query( + value = "SELECT u FROM User u WHERE :other = 'x' ORDER BY CASE WHEN (u.firstname >= :name) THEN 0 ELSE 1 END, u.firstname") + Page findAllOrderedBySpecialNameMultipleParams(@Param("name") String name, @Param("other") String other, + Pageable page); + + // Note that parameters used in the order-by statement are just cut off, so we must declare a query that parameter + // label order remains valid even after truncating the order by part. (i.e. WHERE ?2 = 'x' ORDER BY CASE WHEN + // (u.firstname >= ?1) isn't going to work). + @Query( + value = "SELECT u FROM User u WHERE ?1 = 'x' ORDER BY CASE WHEN (u.firstname >= ?2) THEN 0 ELSE 1 END, u.firstname") + Page findAllOrderedBySpecialNameMultipleParamsIndexed(String other, String name, Pageable page); // DATAJPA-928 Page findByNativeNamedQueryWithPageable(Pageable pageable); @@ -600,7 +617,7 @@ List findUsersByFirstnameForSpELExpressionWithParameterIndexOnlyWithEntity Map findMapWithNullValues(); // DATAJPA-1307 - @Query(value = "select * from SD_User u where u.emailAddress = ?", nativeQuery = true) + @Query(value = "select * from SD_#{#entityName} u where u.emailAddress = ?", nativeQuery = true) User findByEmailNativeAddressJdbcStyleParameter(String emailAddress); // DATAJPA-1334 @@ -610,6 +627,10 @@ List findUsersByFirstnameForSpELExpressionWithParameterIndexOnlyWithEntity @Query("select u from User u where u.lastname like %?#{escape([0])}% escape ?#{escapeCharacter()}") List findContainingEscaped(String namePart); + // GH-3619 + @Query("select u from User u where u.lastname like ?${query.lastname:empty}") + List findWithPropertyPlaceholder(); + // DATAJPA-1303 List findByAttributesIgnoreCaseIn(Collection attributes); @@ -626,13 +647,13 @@ List findUsersByFirstnameForSpELExpressionWithParameterIndexOnlyWithEntity List 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 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 findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter1") String namedParameter1, @Param("namedParameter2") String namedParameter2, Sort sort); @@ -704,8 +725,82 @@ List findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter @Query("select u from User u where u.firstname >= (select Min(u0.firstname) from User u0)") List findProjectionBySubselect(); + @Query("select u from User u") + List findRecordProjection(); + + @Query("select u.firstname as fn, LOWER(u.lastname) as lastname from User u") + List findRecordProjectionWithFunctions(); + + @Query("select u.firstname from User u") + List findStringProjection(); + + @Query("select u.firstname, LOWER(u.lastname) from User u") + List findObjectArrayProjectionWithFunctions(); + + @Query("select u.address from User u") + List findAddressProjection(); + + @Query("select u from User u") + List findRecordProjection(Class projectionType); + + @Query("select u.firstname, u.lastname from User u") + List findMultiselectRecordProjection(); + + /** + * Retrieves a user age by email. + */ + @Query("select u.age from User u where u.emailAddress = ?1") + Optional findAgeByAnnotatedQuery(String emailAddress); + + /** + * Retrieves a user address by email. + */ + @Query("select u.address from User u where u.emailAddress = ?1") + Optional
      findAddressByAnnotatedQuery(String emailAddress); + + /** + * Retrieves a user roles by email. + */ + @Query("select u.roles from User u where u.emailAddress = ?1") + Set 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 dtoProjectionEntityAndAggregatedValue(); + + @UserRoleCountProjectingQuery + Page dtoProjectionEntityAndAggregatedValue(PageRequest page); + + @Query("select u as user, count(r) as roleCount from User u left outer join u.roles r group by u") + List interfaceProjectionEntityAndAggregatedValue(); + + @Query("select u as user, count(r) as roleCount from User u left outer join u.roles r group by u") + List> rawMapProjectionEntityAndAggregatedValue(); + + @UserRoleCountProjectingQuery + List findMultiselectRecordDynamicProjection(Class projectionType); + Window findBy(OffsetScrollPosition position); + @Query("select u from User u where u.lastname like %:name or u.lastname like :name% ORDER BY u.lastname") + List findAnnotatedWithParameterNameQuery(@Param("name") String lastname); + + List 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 { + } + interface RolesAndFirstname { String getFirstname(); @@ -729,4 +824,24 @@ interface EmailOnly { interface IdOnly { int getId(); } + + record UserExcerpt(String firstname, String lastname) { + + } + + record AddressDto(String country, String city) { + public AddressDto(Address address) { + this(address != null ? address.getCountry() : null, address != null ? address.getCity() : null); + } + } + + record UserRoleCountDtoProjection(User user, Long roleCount) { + } + + interface UserRoleCountInterfaceProjection { + User getUser(); + + Long getRoleCount(); + } + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepositoryCustom.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepositoryCustom.java index 485f71b8d4..86f454c0de 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepositoryCustom.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepositoryCustom.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepositoryImpl.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepositoryImpl.java index f27d136730..965834876f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepositoryImpl.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepositoryImpl.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPopulatingMethodInterceptorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPopulatingMethodInterceptorUnitTests.java index c9bcee59a3..723c34a693 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPopulatingMethodInterceptorUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/CrudMethodMetadataPopulatingMethodInterceptorUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaContextIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaContextIntegrationTests.java index 6b88b69bf4..6018de0c11 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaContextIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaContextIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaContextUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaContextUnitTests.java index 1ea6993ce2..cc36108faf 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaContextUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaContextUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. @@ -17,10 +17,11 @@ import static org.assertj.core.api.Assertions.*; -import java.util.Collections; - import jakarta.persistence.EntityManager; +import java.util.Collections; +import java.util.Set; + import org.junit.jupiter.api.Test; /** @@ -34,7 +35,7 @@ class DefaultJpaContextUnitTests { @Test // DATAJPA-669 void rejectsNullEntityManagers() { - assertThatIllegalArgumentException().isThrownBy(() -> new DefaultJpaContext(null)); + assertThatIllegalArgumentException().isThrownBy(() -> new DefaultJpaContext((Set) null)); } @Test // DATAJPA-669 diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaEntityMetadataUnitTest.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaEntityMetadataUnitTest.java index d874935ddb..042ffdd982 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaEntityMetadataUnitTest.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultJpaEntityMetadataUnitTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultQueryHintsTest.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultQueryHintsTest.java index fea4d697b6..c0b69d22e9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultQueryHintsTest.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultQueryHintsTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultTransactionDisablingIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultTransactionDisablingIntegrationTests.java index 1a8e34d1f5..f27993c8e2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultTransactionDisablingIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/DefaultTransactionDisablingIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkJpaMetamodelEntityInformationIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkJpaMetamodelEntityInformationIntegrationTests.java index 4922461995..c18afd1320 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkJpaMetamodelEntityInformationIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkJpaMetamodelEntityInformationIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkJpaRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkJpaRepositoryTests.java index 34c38daa48..95a9308e02 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkJpaRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkJpaRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. @@ -15,7 +15,18 @@ */ package org.springframework.data.jpa.repository.support; +import static org.assertj.core.api.Assertions.*; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import org.springframework.data.jpa.domain.sample.User; import org.springframework.test.context.ContextConfiguration; /** @@ -23,10 +34,49 @@ * * @author Oliver Gierke * @author Greg Turnquist + * @author Mark Paluch */ @ContextConfiguration("classpath:eclipselink.xml") class EclipseLinkJpaRepositoryTests extends JpaRepositoryTests { + @PersistenceContext EntityManager em; + + SimpleJpaRepository repository; + User firstUser, secondUser; + + @BeforeEach + @Override + void setUp() { + + super.setUp(); + + repository = new SimpleJpaRepository<>(User.class, em); + + firstUser = new User("Oliver", "Gierke", "gierke@synyx.de"); + firstUser.setAge(28); + secondUser = new User("Joachim", "Arrasz", "arrasz@synyx.de"); + secondUser.setAge(35); + + repository.deleteAll(); + repository.saveAllAndFlush(List.of(firstUser, secondUser)); + } + + @Test // GH-3990 + void deleteAllBySimpleIdInBatch() { + + repository.deleteAllByIdInBatch(List.of(firstUser.getId(), secondUser.getId())); + + assertThat(repository.count()).isZero(); + } + + @Test // GH-3990 + void deleteAllInBatch() { + + repository.deleteAllInBatch(List.of(firstUser, secondUser)); + + assertThat(repository.count()).isZero(); + } + @Override @Disabled("https://bugs.eclipse.org/bugs/show_bug.cgi?id=349477") void deleteAllByIdInBatch() { @@ -38,4 +88,5 @@ void deleteAllByIdInBatch() { void deleteAllByIdInBatchShouldConvertAnIterableToACollection() { // disabled } + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkProxyIdAccessorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkProxyIdAccessorTests.java index eb619200c7..c7259a5b17 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkProxyIdAccessorTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkProxyIdAccessorTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityGraphFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityGraphFactoryUnitTests.java index 647896ffbb..c2039fcd60 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityGraphFactoryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityGraphFactoryUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2021-2024 the original author or authors. + * Copyright 2021-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessorIntegrationTests.java index a1d020423d..2ac1d8a501 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessorIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessorIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. @@ -38,6 +38,7 @@ * @author Oliver Gierke * @author Jens Schauder * @author Réda Housni Alaoui + * @author Mark Paluch */ @ExtendWith(SpringExtension.class) @ContextConfiguration @@ -61,7 +62,7 @@ static class Config { @Autowired @Qualifier("entityManagerFactory") EntityManagerFactory emf; @Bean - public static EntityManagerBeanDefinitionRegistrarPostProcessor processor() { + static EntityManagerBeanDefinitionRegistrarPostProcessor postProcessor() { return new EntityManagerBeanDefinitionRegistrarPostProcessor(); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessorUnitTests.java index 8a6725a615..a34fc121fa 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessorUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessorUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerFactoryRefTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerFactoryRefTests.java index e5848bfef9..50380485a6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerFactoryRefTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerFactoryRefTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerFactoryRefUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerFactoryRefUnitTests.java index b25682e9a8..a9ea88ad00 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerFactoryRefUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerFactoryRefUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicateUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicateUnitTests.java index f2c5e9f00c..f537f314a5 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicateUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicateUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2024 the original author or authors. + * 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. @@ -20,6 +20,8 @@ import org.junit.jupiter.api.Test; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; +import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; /** * Unit tests for {@link FetchableFluentQueryByPredicate}. @@ -32,10 +34,13 @@ class FetchableFluentQueryByPredicateUnitTests { @SuppressWarnings({ "rawtypes", "unchecked" }) void multipleSortBy() { + JpaEntityInformationSupport entityInformation = new JpaEntityInformationSupportUnitTests.DummyJpaEntityInformation( + User.class); + Sort s1 = Sort.by(Order.by("s1")); Sort s2 = Sort.by(Order.by("s2")); - FetchableFluentQueryByPredicate f = new FetchableFluentQueryByPredicate(null, null, null, null, null, null, null, - null, null); + FetchableFluentQueryByPredicate f = new FetchableFluentQueryByPredicate(null, null, entityInformation, null, null, + null, null, null, null, new SpelAwareProxyProjectionFactory()); f = (FetchableFluentQueryByPredicate) f.sortBy(s1).sortBy(s2); assertThat(f.sort).isEqualTo(s1.and(s2)); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/HibernateJpaMetamodelEntityInformationIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/HibernateJpaMetamodelEntityInformationIntegrationTests.java index c305e9b4bc..deffda2a29 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/HibernateJpaMetamodelEntityInformationIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/HibernateJpaMetamodelEntityInformationIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -17,7 +17,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.data.jpa.util.DisabledOnHibernate61; + +import org.springframework.data.jpa.util.DisabledOnHibernate; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -35,21 +36,21 @@ String getMetadadataPersistenceUnitName() { return "metadata-id-handling"; } - @DisabledOnHibernate61 + @DisabledOnHibernate("6.1") @Test @Override void correctlyDeterminesIdValueForNestedIdClassesWithNonPrimitiveNonManagedType() { super.correctlyDeterminesIdValueForNestedIdClassesWithNonPrimitiveNonManagedType(); } - @DisabledOnHibernate61 + @DisabledOnHibernate("6.1") @Test @Override void prefersPrivateGetterOverFieldAccess() { super.prefersPrivateGetterOverFieldAccess(); } - @DisabledOnHibernate61 + @DisabledOnHibernate("6.1") @Test @Override void findsIdClassOnMappedSuperclass() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JavaConfigDefaultTransactionDisablingIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JavaConfigDefaultTransactionDisablingIntegrationTests.java index 0a5e0d9199..7d985c050d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JavaConfigDefaultTransactionDisablingIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JavaConfigDefaultTransactionDisablingIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupportUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupportUnitTests.java index e66e624b12..fd66850d50 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupportUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupportUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -53,10 +53,10 @@ class JpaEntityInformationSupportUnitTests { @Mock PersistenceUnitUtil persistenceUnitUtil; @Test - void usesSimpleClassNameIfNoEntityNameGiven() { + void usesUnqualifiedClassNameIfNoEntityNameGiven() { JpaEntityInformation information = new DummyJpaEntityInformation<>(User.class); - assertThat(information.getEntityName()).isEqualTo("User"); + assertThat(information.getEntityName()).isEqualTo(getClass().getSimpleName() + "$" + User.class.getSimpleName()); JpaEntityInformation second = new DummyJpaEntityInformation(NamedUser.class); assertThat(second.getEntityName()).isEqualTo("AnotherNamedUser"); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformationIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformationIntegrationTests.java index 0f5c9ab3a7..a04a0ee1c4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformationIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformationIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformationUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformationUnitTests.java index 1bed3b7fdf..476957a8d2 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformationUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformationUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2012-2024 the original author or authors. + * Copyright 2012-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaPersistableEntityInformationUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaPersistableEntityInformationUnitTests.java index 37050ffe89..03e8b764bb 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaPersistableEntityInformationUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaPersistableEntityInformationUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBeanEntityPathResolverIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBeanEntityPathResolverIntegrationTests.java index 73a3c76ce3..d5f1f891af 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBeanEntityPathResolverIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBeanEntityPathResolverIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBeanUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBeanUnitTests.java index bfbbe561cb..dad06c1f9d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBeanUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBeanUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. 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 983c7c2195..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -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..f3634a37eb --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFragmentsContributorUnitTests.java @@ -0,0 +1,100 @@ +/* + * 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 jakarta.persistence.EntityManagerFactory; + +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 + * @author Ariel Morelli Andres + */ +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); + EntityManagerFactory emf = mock(EntityManagerFactory.class); + when(entityManager.getDelegate()).thenReturn(entityManager); + when(entityManager.getEntityManagerFactory()).thenReturn(emf); + + RepositoryComposition.RepositoryFragments fragments = contributor.contribute( + AbstractRepositoryMetadata.getMetadata(QuerydslUserRepository.class), + new JpaEntityInformationSupportUnitTests.DummyJpaEntityInformation<>(QuerydslUserRepository.class), + entityManager, entityPathResolver); + + assertThat(fragments).hasSize(2); + + Iterator> 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, QuerydslPredicateExecutor {} + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryTests.java index 27daa255a8..535d8a4294 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -24,7 +24,6 @@ import java.util.Iterator; import java.util.List; -import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -151,7 +150,6 @@ void deleteAllByIdInBatchShouldConvertAnIterableToACollection() { private List ids = Arrays.asList(new SampleEntityPK("one", "eins"), new SampleEntityPK("three", "drei")); - @NotNull @Override public Iterator iterator() { return ids.iterator(); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/MailMessageRepositoryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/MailMessageRepositoryIntegrationTests.java index b813ae8714..3688699d61 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/MailMessageRepositoryIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/MailMessageRepositoryIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/MutableQueryHintsUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/MutableQueryHintsUnitTests.java index e4c0c3c7c0..c47f5c5c75 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/MutableQueryHintsUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/MutableQueryHintsUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2020-2024 the original author or authors. + * Copyright 2020-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. 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 70a8e8ed87..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaMetamodelEntityInformationIntegrationTests.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2013-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.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 6162daab39..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaProxyIdAccessorTests.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2014-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.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/QSimpleEntityPathResolverUnitTests_Sample.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QSimpleEntityPathResolverUnitTests_Sample.java index 2f33aaafd0..773d82abbe 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QSimpleEntityPathResolverUnitTests_Sample.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QSimpleEntityPathResolverUnitTests_Sample.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslIntegrationTests.java index 2c71c33483..4a59ea90a4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. 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 7962695b6a..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 @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. @@ -23,17 +23,18 @@ import java.sql.Date; import java.time.LocalDate; import java.util.List; -import java.util.Set; import java.util.stream.Stream; import org.hibernate.LazyInitializationException; 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.Slice; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.domain.Sort.Order; @@ -212,7 +213,7 @@ void findBySpecificationWithSortByQueryDslOrderSpecifierWithQPageRequestAndQSort QUser user = QUser.user; Page 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); } @@ -223,7 +224,7 @@ void findBySpecificationWithSortByQueryDslOrderSpecifierWithQPageRequest() { QUser user = QUser.user; Page 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); } @@ -237,7 +238,7 @@ void findBySpecificationWithSortByQueryDslOrderSpecifierForAssociationShouldGene QUser user = QUser.user; Page 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); } @@ -282,9 +283,15 @@ void shouldSupportFindAllWithPredicateAndSort() { assertThat(users).contains(carter, dave, oliver); } - @Test // DATAJPA-585 + @Test // DATAJPA-585, 3761 void worksWithUnpagedPageable() { + assertThat(predicateExecutor.findAll(user.dateOfBirth.isNull(), Pageable.unpaged()).getContent()).hasSize(3); + + Page users = predicateExecutor.findAll(user.dateOfBirth.isNull(), + Pageable.unpaged(Sort.by(Direction.ASC, "firstname"))); + + assertThat(users).containsExactly(carter, dave, oliver); } @Test // DATAJPA-912 @@ -393,6 +400,35 @@ void findByFluentPredicatePage() { assertThat(page1.getContent()).containsExactly(oliver); } + @Test // GH-3764 + void findByFluentPredicateSlice() { + + Predicate predicate = user.firstname.contains("v"); + + Slice slice0 = predicateExecutor.findBy(predicate, + q -> q.sortBy(Sort.by("firstname")).slice(PageRequest.of(0, 1))); + + Slice slice1 = predicateExecutor.findBy(predicate, + q -> q.sortBy(Sort.by("firstname")).slice(PageRequest.of(1, 1))); + + assertThat(slice0.getContent()).containsExactly(dave); + assertThat(slice0.hasNext()).isTrue(); + assertThat(slice1.getContent()).containsExactly(oliver); + assertThat(slice1.hasNext()).isFalse(); + } + + @Test // GH-3762 + void findByFluentPredicateSortOverridePage() { + + Predicate predicate = user.firstname.contains("v"); + + Page page = predicateExecutor.findBy(predicate, + q -> q.sortBy(Sort.by("firstname")).page(PageRequest.of(0, 1, Sort.by(Direction.DESC, "firstname")))); + + assertThat(page.getContent()).containsOnly(oliver); + assertThat(predicateExecutor.findAll(predicate, page.nextPageable())).containsOnly(dave); + } + @Test // GH-2294 void findByFluentPredicateWithInterfaceBasedProjection() { @@ -403,6 +439,16 @@ void findByFluentPredicateWithInterfaceBasedProjection() { .containsExactlyInAnyOrder(dave.getFirstname(), oliver.getFirstname()); } + @Test // GH-2327 + void findByFluentPredicateWithDtoProjection() { + + List users = predicateExecutor.findBy(user.firstname.contains("v"), + q -> q.as(UserProjectionDto.class).all()); + + assertThat(users).extracting(UserProjectionDto::firstname).containsExactlyInAnyOrder(dave.getFirstname(), + oliver.getFirstname()); + } + @Test // GH-2294 void findByFluentPredicateWithSortedInterfaceBasedProjection() { @@ -435,31 +481,6 @@ void existsByFluentPredicate() { assertThat(exists).isTrue(); } - @Test // GH-2294 - void fluentExamplesWithClassBasedDtosNotYetSupported() { - - class UserDto { - String firstname; - - public UserDto() {} - - public String getFirstname() { - return this.firstname; - } - - public void setFirstname(String firstname) { - this.firstname = firstname; - } - - public String toString() { - return "UserDto(firstname=" + this.getFirstname() + ")"; - } - } - - assertThatExceptionOfType(UnsupportedOperationException.class).isThrownBy(() -> predicateExecutor - .findBy(user.firstname.contains("v"), q -> q.as(UserDto.class).sortBy(Sort.by("firstname")).all())); - } - @Test // GH-2329 void findByFluentPredicateWithSimplePropertyPathsDoesntLoadUnrequestedPaths() { @@ -530,10 +551,24 @@ 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(); - Set getRoles(); + String getLastname(); + } + + public record UserProjectionDto(String firstname, String lastname) { } } 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 7e4778d531..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepositoryTests.java +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright 2008-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.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 repository; - private QUser user = new QUser("user"); - private User dave; - private User carter; - private User oliver; - private Role adminRole; - - @BeforeEach - void setUp() { - - JpaEntityInformation 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 result = repository.findAll(isCalledDave.or(isBeauford)); - - assertThat(result).containsExactlyInAnyOrder(carter, dave); - } - - @Test - void executesStringBasedPredicatesCorrectly() { - - PathBuilder builder = new PathBuilderFactory().create(User.class); - - BooleanExpression isCalledDave = builder.getString("firstname").eq("Dave"); - BooleanExpression isBeauford = builder.getString("lastname").eq("Beauford"); - - List result = repository.findAll(isCalledDave.or(isBeauford)); - - assertThat(result).containsExactlyInAnyOrder(carter, dave); - } - - @Test // DATAJPA-243 - void considersSortingProvidedThroughPageable() { - - Predicate lastnameContainsE = user.lastname.contains("e"); - - Page 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 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 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 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 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 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 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 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 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 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 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 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 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 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 firstPage = repository.findAll(user.dateOfBirth.isNull(), PageRequest.of(0, 10)); - assertThat(firstPage.getContent()).hasSize(3); - assertThat(firstPage.getTotalElements()).isEqualTo(3L); - - Page 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 firstPage = repository.findAll(user.dateOfBirth.isNull(), PageRequest.of(0, 3)); - assertThat(firstPage.getContent()).hasSize(3); - assertThat(firstPage.getTotalElements()).isEqualTo(3L); - - Page 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/QuerydslRepositorySupportIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupportIntegrationTests.java index 6c400a05c4..81a9edc627 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupportIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupportIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -61,11 +61,6 @@ public void setEntityManager(EntityManager entityManager) { }; } - @Bean - static EntityManagerBeanDefinitionRegistrarPostProcessor entityManagerBeanDefinitionRegistrarPostProcessor() { - return new EntityManagerBeanDefinitionRegistrarPostProcessor(); - } - @Bean public ReconfiguringUserRepositoryImpl reconfiguringUserRepositoryImpl() { return new ReconfiguringUserRepositoryImpl(); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupportTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupportTests.java index 1eb68aa08b..4e1e1d650a 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupportTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupportTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. 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 5b83077bbb..0d1e8133f5 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 @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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,9 @@ */ package org.springframework.data.jpa.repository.support; -import static java.util.Collections.singletonMap; -import static org.assertj.core.api.Assertions.assertThatNoException; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static org.springframework.data.jpa.domain.Specification.where; +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; import jakarta.persistence.EntityGraph; import jakarta.persistence.EntityManager; @@ -31,20 +27,28 @@ import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.Arrays; -import java.util.Optional; +import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; 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; +import org.springframework.transaction.annotation.Transactional; /** * Unit tests for {@link SimpleJpaRepository}. @@ -54,6 +58,8 @@ * @author Mark Paluch * @author Jens Schauder * @author Greg Turnquist + * @author Yanming Zhou + * @author Ariel Morelli Andres */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -78,6 +84,9 @@ class SimpleJpaRepositoryUnitTests { void setUp() { when(em.getDelegate()).thenReturn(em); + when(em.getEntityManagerFactory()).thenReturn(entityManagerFactory); + + when(entityManagerFactory.getPersistenceUnitUtil()).thenReturn(persistenceUnitUtil); when(information.getJavaType()).thenReturn(User.class); when(em.getCriteriaBuilder()).thenReturn(builder); @@ -138,7 +147,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)); @@ -180,7 +189,6 @@ void doNothingWhenNewInstanceGetsDeleted() { newUser.setId(null); when(em.getEntityManagerFactory()).thenReturn(entityManagerFactory); - when(entityManagerFactory.getPersistenceUnitUtil()).thenReturn(persistenceUnitUtil); repo.delete(newUser); @@ -197,7 +205,6 @@ void doNothingWhenNonExistentInstanceGetsDeleted() { when(information.isNew(newUser)).thenReturn(false); when(em.getEntityManagerFactory()).thenReturn(entityManagerFactory); - when(entityManagerFactory.getPersistenceUnitUtil()).thenReturn(persistenceUnitUtil); when(persistenceUnitUtil.getIdentifier(any())).thenReturn(23); when(em.find(User.class, 23)).thenReturn(null); @@ -212,8 +219,34 @@ 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(); } + + @ParameterizedTest // GH-3188 + @MethodSource("modifyingMethods") + void checkTransactionalAnnotation(Method method) { + + Transactional transactional = method.getAnnotation(Transactional.class); + if (transactional == null) { + transactional = method.getDeclaringClass().getAnnotation(Transactional.class); + } + + assertThat(transactional).isNotNull(); + assertThat(transactional.readOnly()).isFalse(); + } + + static Stream modifyingMethods() { + + return Stream.of(SimpleJpaRepository.class.getDeclaredMethods()) + .filter(method -> Modifier.isPublic(method.getModifiers())) // + .filter(method -> !method.isBridge()) // + .filter(method -> method.getName().startsWith("delete") || method.getName().startsWith("save")) + .map(method -> Arguments.argumentSet(formatName(method), method)); + } + + private static String formatName(Method method) { + return method.toString().replaceAll("public ", "").replaceAll(SimpleJpaRepository.class.getName() + ".", ""); + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TestcontainerConfigSupport.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TestcontainerConfigSupport.java new file mode 100644 index 0000000000..505ad4b089 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TestcontainerConfigSupport.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.support; + +import jakarta.persistence.EntityManagerFactory; + +import java.util.Collection; +import java.util.Collections; +import java.util.Properties; + +import javax.sql.DataSource; + +import org.springframework.context.annotation.Bean; +import org.springframework.core.io.Resource; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.init.DataSourceInitializer; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.persistenceunit.ManagedClassNameFilter; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.transaction.PlatformTransactionManager; + +import org.testcontainers.containers.JdbcDatabaseContainer; + +/** + * Support class for integration testing with Testcontainers + * + * @author Mark Paluch + */ +public class TestcontainerConfigSupport { + + private final Class dialect; + private final Resource initScript; + + protected TestcontainerConfigSupport(Class dialect, Resource initScript) { + this.dialect = dialect; + this.initScript = initScript; + } + + @Bean + DataSource dataSource(JdbcDatabaseContainer container) { + + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setUrl(container.getJdbcUrl()); + dataSource.setUsername(container.getUsername()); + dataSource.setPassword(container.getPassword()); + + return dataSource; + } + + @Bean + AbstractEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { + + LocalContainerEntityManagerFactoryBean factoryBean = new LocalContainerEntityManagerFactoryBean(); + factoryBean.setDataSource(dataSource); + factoryBean.setPersistenceUnitRootLocation("simple-persistence"); + factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); + + factoryBean.setManagedTypes(getManagedTypes()); + factoryBean.setPackagesToScan(getPackagesToScan().toArray(new String[0])); + factoryBean.setManagedClassNameFilter(getManagedClassNameFilter()); + + Properties properties = new Properties(); + properties.setProperty("hibernate.hbm2ddl.auto", getSchemaAction()); + properties.setProperty("hibernate.dialect", dialect.getCanonicalName()); + + factoryBean.setJpaProperties(properties); + + return factoryBean; + } + + protected String getSchemaAction() { + return "create"; + } + + protected PersistenceManagedTypes getManagedTypes() { + return null; + } + + protected Collection getPackagesToScan() { + return Collections.emptyList(); + } + + protected ManagedClassNameFilter getManagedClassNameFilter() { + return className -> true; + } + + @Bean + PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) { + return new JpaTransactionManager(entityManagerFactory); + } + + @Bean + DataSourceInitializer initializer(DataSource dataSource) { + + DataSourceInitializer initializer = new DataSourceInitializer(); + initializer.setDataSource(dataSource); + + ResourceDatabasePopulator populator = new ResourceDatabasePopulator(initScript); + populator.setSeparator(";;"); + initializer.setDatabasePopulator(populator); + + return initializer; + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TransactionalRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TransactionalRepositoryTests.java index 5a29269c82..be195c947c 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TransactionalRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TransactionalRepositoryTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2008-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/XmlConfigDefaultTransactionDisablingIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/XmlConfigDefaultTransactionDisablingIntegrationTests.java index df684286e9..839293c009 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/XmlConfigDefaultTransactionDisablingIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/XmlConfigDefaultTransactionDisablingIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessorUnitTests.java index 9e60043821..e60565e8c8 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessorUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessorUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/EntityManagerTestUtils.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/EntityManagerTestUtils.java index 013857d487..479c69c015 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/EntityManagerTestUtils.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/EntityManagerTestUtils.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/MergingPersistenceUnitManagerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/MergingPersistenceUnitManagerUnitTests.java index 495c89d71f..feb698660b 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/MergingPersistenceUnitManagerUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/MergingPersistenceUnitManagerUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2024 the original author or authors. + * Copyright 2011-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. @@ -46,7 +46,7 @@ @MockitoSettings(strictness = Strictness.LENIENT) class MergingPersistenceUnitManagerUnitTests { - @Mock PersistenceUnitInfo oldInfo; + @Mock MutablePersistenceUnitInfo oldInfo; @Mock MutablePersistenceUnitInfo newInfo; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/ProxyImageNameSubstitutor.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/ProxyImageNameSubstitutor.java index 9ea6d2b235..89e64a3024 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/ProxyImageNameSubstitutor.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/support/ProxyImageNameSubstitutor.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. @@ -23,7 +23,7 @@ /** * An {@link ImageNameSubstitutor} only used on CI servers to leverage internal proxy solution, that needs to vary the * prefix based on which container image is needed. - * + * * @author Greg Turnquist */ public class ProxyImageNameSubstitutor extends ImageNameSubstitutor { @@ -32,7 +32,7 @@ public class ProxyImageNameSubstitutor extends ImageNameSubstitutor { private static final List NAMES_TO_LIBRARY_PROXY_PREFIX = List.of("mysql", "postgres"); - private static final String PROXY_PREFIX = "harbor-repo.vmware.com/dockerhub-proxy-cache/"; + private static final String PROXY_PREFIX = "docker-hub.usw1.packages.broadcom.com/"; private static final String LIBRARY_PROXY_PREFIX = PROXY_PREFIX + "library/"; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/BooleanExecutionCondition.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/BooleanExecutionCondition.java new file mode 100644 index 0000000000..590d92d4d0 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/BooleanExecutionCondition.java @@ -0,0 +1,58 @@ +/* + * 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 static org.junit.jupiter.api.extension.ConditionEvaluationResult.*; +import static org.junit.platform.commons.util.AnnotationUtils.*; + +import java.lang.annotation.Annotation; +import java.util.function.Function; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; + +abstract class BooleanExecutionCondition implements ExecutionCondition { + + private final Class annotationType; + private final String enabledReason; + private final String disabledReason; + private final Function customDisabledReason; + + BooleanExecutionCondition(Class annotationType, String enabledReason, String disabledReason, + Function customDisabledReason) { + this.annotationType = annotationType; + this.enabledReason = enabledReason; + this.disabledReason = disabledReason; + this.customDisabledReason = customDisabledReason; + } + + abstract boolean isEnabled(A annotation); + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + return findAnnotation(context.getElement(), annotationType) // + .map(annotation -> isEnabled(annotation) ? enabled(enabledReason) + : disabled(disabledReason, customDisabledReason.apply(annotation))) // + .orElseGet(this::enabledByDefault); + } + + private ConditionEvaluationResult enabledByDefault() { + String reason = String.format("@%s is not present", annotationType.getSimpleName()); + return enabled(reason); + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernate61.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusions.java similarity index 54% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernate61.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusions.java index 451eb8866a..f53d4d6ce4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernate61.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusions.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 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,6 +15,7 @@ */ package org.springframework.data.jpa.util; +import java.lang.annotation.Documented; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -23,14 +24,22 @@ import org.junit.jupiter.api.extension.ExtendWith; /** - * Annotation to flag JUnit 5 test cases to ONLY activate when Hibernate 6.2 is on the classpath. + * Annotation used to exclude entries from the classpath. Simplified version of ClassPathExclusions. * - * @author Greg Turnquist - * @since 3.1 + * @author Christoph Strobl */ -@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) @Retention(RetentionPolicy.RUNTIME) -@ExtendWith(HibernateSupport.DisabledWhenHibernate61OnClasspath.class) -public @interface DisabledOnHibernate61 { +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Documented +@ExtendWith(ClassPathExclusionsExtension.class) +public @interface ClassPathExclusions { + + /** + * One or more packages that should be excluded from the classpath. + * + * @return the excluded packages + */ + String[] packages(); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusionsExtension.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusionsExtension.java new file mode 100644 index 0000000000..b8d76f9285 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/ClassPathExclusionsExtension.java @@ -0,0 +1,130 @@ +/* + * 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 java.lang.reflect.Method; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.InvocationInterceptor; +import org.junit.jupiter.api.extension.ReflectiveInvocationContext; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.TestPlan; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.junit.platform.launcher.listeners.TestExecutionSummary; +import org.springframework.util.CollectionUtils; + +/** + * Simplified version of ModifiedClassPathExtension. + * + * @author Christoph Strobl + */ +class ClassPathExclusionsExtension implements InvocationInterceptor { + + @Override + public void interceptBeforeAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptBeforeEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptAfterEachMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptAfterAllMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + intercept(invocation, extensionContext); + } + + @Override + public void interceptTestMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + interceptMethod(invocation, invocationContext, extensionContext); + } + + @Override + public void interceptTestTemplateMethod(Invocation invocation, + ReflectiveInvocationContext invocationContext, ExtensionContext extensionContext) throws Throwable { + interceptMethod(invocation, invocationContext, extensionContext); + } + + private void interceptMethod(Invocation invocation, ReflectiveInvocationContext invocationContext, + ExtensionContext extensionContext) throws Throwable { + + if (isModifiedClassPathClassLoader(extensionContext)) { + invocation.proceed(); + return; + } + + Class testClass = extensionContext.getRequiredTestClass(); + Method testMethod = invocationContext.getExecutable(); + PackageExcludingClassLoader modifiedClassLoader = PackageExcludingClassLoader.get(testClass, testMethod); + if (modifiedClassLoader == null) { + invocation.proceed(); + return; + } + invocation.skip(); + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + Thread.currentThread().setContextClassLoader(modifiedClassLoader); + try { + runTest(extensionContext.getUniqueId()); + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + } + + private void runTest(String testId) throws Throwable { + + LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request() + .selectors(DiscoverySelectors.selectUniqueId(testId)).build(); + Launcher launcher = LauncherFactory.create(); + TestPlan testPlan = launcher.discover(request); + SummaryGeneratingListener listener = new SummaryGeneratingListener(); + launcher.registerTestExecutionListeners(listener); + launcher.execute(testPlan); + TestExecutionSummary summary = listener.getSummary(); + if (!CollectionUtils.isEmpty(summary.getFailures())) { + throw summary.getFailures().get(0).getException(); + } + } + + private void intercept(Invocation invocation, ExtensionContext extensionContext) throws Throwable { + if (isModifiedClassPathClassLoader(extensionContext)) { + invocation.proceed(); + return; + } + invocation.skip(); + } + + private boolean isModifiedClassPathClassLoader(ExtensionContext extensionContext) { + Class testClass = extensionContext.getRequiredTestClass(); + ClassLoader classLoader = testClass.getClassLoader(); + return classLoader.getClass().getName().equals(PackageExcludingClassLoader.class.getName()); + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernate.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernate.java new file mode 100644 index 0000000000..cdae0ff329 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernate.java @@ -0,0 +1,53 @@ +/* + * 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.util; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * {@code @DisabledOnHibernate} is used to signal that the annotated test class or test method is only disabled + * if the given Hibernate {@linkplain #value version} is being used. + * + * @author Greg Turnquist + * @author Mark Paluch + * @since 3.2 + */ +@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(DisabledOnHibernateCondition.class) +public @interface DisabledOnHibernate { + + /** + * The version of Hibernate to disable the test or container case on. The version specifier can hold individual + * version components matching effectively the version in a prefix-manner. The more specific you want to match, the + * more version components you can specify, such as {@code 6.2.1} to match a specific service release or {@code 6} to + * match a full major version. + */ + String value(); + + /** + * Custom reason to provide if the test or container is disabled. + *

      + * If a custom reason is supplied, it will be combined with the default reason for this annotation. If a custom reason + * is not supplied, the default reason will be used. + */ + String disabledReason() default ""; +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernate62.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernate62.java deleted file mode 100644 index b39f7000de..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernate62.java +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2015-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.util; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -import org.junit.jupiter.api.extension.ExtendWith; - -/** - * Annotation to flag JUnit 5 test cases to ONLY activate when Hibernate 6.1 is on the classpath. - * - * @author Greg Turnquist - * @since 3.1 - */ -@Target({ ElementType.TYPE, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) -@Retention(RetentionPolicy.RUNTIME) -@ExtendWith(HibernateSupport.DisabledWhenHibernate62OnClasspath.class) -public @interface DisabledOnHibernate62 { - -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernateCondition.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernateCondition.java new file mode 100644 index 0000000000..21e2c87b2d --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernateCondition.java @@ -0,0 +1,100 @@ +/* + * 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 java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.extension.ExecutionCondition; + +/** + * {@link ExecutionCondition} for {@link DisabledOnHibernate @DisabledOnHibernate}. + * + * @see DisabledOnHibernate + */ +class DisabledOnHibernateCondition extends BooleanExecutionCondition { + + static final String ENABLED_ON_CURRENT_HIBERNATE = // + "Enabled on Hibernate version: " + org.hibernate.Version.getVersionString(); + + static final String DISABLED_ON_CURRENT_HIBERNATE = // + "Disabled on Hibernate version: " + org.hibernate.Version.getVersionString(); + + DisabledOnHibernateCondition() { + super(DisabledOnHibernate.class, ENABLED_ON_CURRENT_HIBERNATE, DISABLED_ON_CURRENT_HIBERNATE, + DisabledOnHibernate::disabledReason); + } + + @Override + boolean isEnabled(DisabledOnHibernate annotation) { + + VersionMatcher disabled = VersionMatcher.parse(annotation.value()); + VersionMatcher hibernate = VersionMatcher.parse(org.hibernate.Version.getVersionString()); + + return !disabled.matches(hibernate); + } + + static class VersionMatcher { + + private static final Pattern PATTERN = Pattern.compile("(\\d+)+"); + private final int[] components; + + private VersionMatcher(int[] components) { + this.components = components; + } + + /** + * Parse the given version string into a {@link VersionMatcher}. + * + * @param version + * @return + */ + public static VersionMatcher parse(String version) { + + Matcher matcher = PATTERN.matcher(version); + List ints = new ArrayList<>(); + while (matcher.find()) { + ints.add(Integer.parseInt(matcher.group())); + } + + return new VersionMatcher(ints.stream().mapToInt(value -> value).toArray()); + } + + /** + * Match the given version against another VersionMatcher. This matcher's version spec controls the expected length. + * If the other version is shorter, then the match returns {@code false}. + * + * @param version + * @return + */ + public boolean matches(VersionMatcher version) { + + for (int i = 0; i < components.length; i++) { + if (version.components.length <= i) { + return false; + } + if (components[i] != version.components[i]) { + return false; + } + } + + return true; + } + } + +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernateConditionTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernateConditionTests.java new file mode 100644 index 0000000000..80750aeb5d --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/DisabledOnHibernateConditionTests.java @@ -0,0 +1,56 @@ +/* + * 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 static org.assertj.core.api.Assertions.*; +import static org.springframework.data.jpa.util.DisabledOnHibernateCondition.*; + +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link DisabledOnHibernate}. + * + * @author Mark Paluch + */ +class DisabledOnHibernateConditionTests { + + @Test // GH-3081 + void shouldMatchVersions() { + + VersionMatcher spec = VersionMatcher.parse("1.2"); + VersionMatcher lib = VersionMatcher.parse("v1.2.3.4.Final"); + + assertThat(spec.matches(lib)).isTrue(); + } + + @Test // GH-3081 + void shouldNotMatchVersions() { + + VersionMatcher spec = VersionMatcher.parse("1.2"); + VersionMatcher lib = VersionMatcher.parse("2.2.3.4"); + + assertThat(spec.matches(lib)).isFalse(); + } + + @Test // GH-3081 + void shouldNotMatchVersionsWithExceedingLength() { + + VersionMatcher spec = VersionMatcher.parse("1.2.3.4"); + VersionMatcher lib = VersionMatcher.parse("1.2"); + + assertThat(spec.matches(lib)).isFalse(); + } +} 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 377874e48c..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 @@ -1,5 +1,5 @@ /* - * Copyright 2015-2024 the original author or authors. + * 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. @@ -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/HibernateSupport.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/HibernateSupport.java deleted file mode 100644 index d2b3253540..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/HibernateSupport.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2015-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.util; - -import org.junit.jupiter.api.extension.ConditionEvaluationResult; -import org.junit.jupiter.api.extension.ExecutionCondition; -import org.junit.jupiter.api.extension.ExtensionContext; -import org.springframework.util.ClassUtils; - -/** - * JUnit 5 utilities to support conditional test cases based upon Hibernate classpath settings. - * - * @author Greg Turnquist - * @since 3.1 - */ -abstract class HibernateSupport { - - /** - * {@literal org.hibernate.dialect.PostgreSQL91Dialect} is deprecated in Hibernate 6.1 and fully removed in Hibernate - * 6.2, making it a perfect detector between the two. - */ - private static final boolean HIBERNATE_61_ON_CLASSPATH = ClassUtils - .isPresent("org.hibernate.dialect.PostgreSQL91Dialect", HibernateSupport.class.getClassLoader()); - - private static final boolean HIBERNATE_62_ON_CLASSPATH = !HIBERNATE_61_ON_CLASSPATH; - - static class DisabledWhenHibernate61OnClasspath implements ExecutionCondition { - - @Override - public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext extensionContext) { - - return HibernateSupport.HIBERNATE_61_ON_CLASSPATH - ? ConditionEvaluationResult.disabled("Disabled because Hibernate 6.1 is on the classpath") - : ConditionEvaluationResult.enabled("NOT disabled because Hibernate 6.2 is on the classpath"); - } - } - - static class DisabledWhenHibernate62OnClasspath implements ExecutionCondition { - - @Override - public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext extensionContext) { - - return HibernateSupport.HIBERNATE_62_ON_CLASSPATH - ? ConditionEvaluationResult.disabled("Disabled because Hibernate 6.2 is on the classpath") - : ConditionEvaluationResult.enabled("NOT disabled because Hibernate 6.1 is on the classpath"); - } - - } -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/HidingClassLoader.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/HidingClassLoader.java index ce60e381e2..2ed67c83f1 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/HidingClassLoader.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/HidingClassLoader.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2024 the original author or authors. + * 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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/JpaMetamodelCacheCleanupIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/JpaMetamodelCacheCleanupIntegrationTests.java index 19f03a9225..65cb8ea1ad 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/JpaMetamodelCacheCleanupIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/JpaMetamodelCacheCleanupIntegrationTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/JpaMetamodelUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/JpaMetamodelUnitTests.java index a4cd6592bf..73102d6d03 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/JpaMetamodelUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/JpaMetamodelUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2018-2024 the original author or authors. + * Copyright 2018-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. diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/PackageExcludingClassLoader.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/PackageExcludingClassLoader.java new file mode 100644 index 0000000000..b5c8ed5216 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/PackageExcludingClassLoader.java @@ -0,0 +1,143 @@ +/* + * 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 java.io.File; +import java.lang.management.ManagementFactory; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.BinaryOperator; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collector; +import java.util.stream.Stream; + +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.util.ClassUtils; + +/** + * Simplified version of ModifiedClassPathClassLoader. + * + * @author Christoph Strobl + */ +class PackageExcludingClassLoader extends URLClassLoader { + + private final Set excludedPackages; + private final ClassLoader junitLoader; + + PackageExcludingClassLoader(URL[] urls, ClassLoader parent, Collection excludedPackages, + ClassLoader junitClassLoader) { + + super(urls, parent); + this.excludedPackages = Set.copyOf(excludedPackages); + this.junitLoader = junitClassLoader; + } + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + + if (name.startsWith("org.junit") || name.startsWith("org.hamcrest")) { + return Class.forName(name, false, this.junitLoader); + } + + String packageName = ClassUtils.getPackageName(name); + if (this.excludedPackages.contains(packageName)) { + throw new ClassNotFoundException(name); + } + return super.loadClass(name); + } + + static PackageExcludingClassLoader get(Class testClass, Method testMethod) { + + List excludedPackages = readExcludedPackages(testClass, testMethod); + + if (excludedPackages.isEmpty()) { + return null; + } + + ClassLoader testClassClassLoader = testClass.getClassLoader(); + Stream urls = null; + if (testClassClassLoader instanceof URLClassLoader urlClassLoader) { + urls = Stream.of(urlClassLoader.getURLs()); + } else { + urls = Stream.of(ManagementFactory.getRuntimeMXBean().getClassPath().split(File.pathSeparator)) + .map(PackageExcludingClassLoader::toURL); + } + + return new PackageExcludingClassLoader(urls.toArray(URL[]::new), testClassClassLoader.getParent(), excludedPackages, + testClassClassLoader); + } + + private static List readExcludedPackages(Class testClass, Method testMethod) { + + return Stream.of( // + AnnotatedElementUtils.findMergedAnnotation(testClass, ClassPathExclusions.class), + AnnotatedElementUtils.findMergedAnnotation(testMethod, ClassPathExclusions.class) // + ).filter(Objects::nonNull) // + .map(ClassPathExclusions::packages) // + .collect(new CombingArrayCollector()); + } + + private static URL toURL(String entry) { + try { + return new File(entry).toURI().toURL(); + } catch (Exception ex) { + throw new IllegalArgumentException(ex); + } + } + + private static class CombingArrayCollector implements Collector, List> { + + @Override + public Supplier> supplier() { + return ArrayList::new; + } + + @Override + public BiConsumer, T[]> accumulator() { + return (target, values) -> target.addAll(Arrays.asList(values)); + } + + @Override + public BinaryOperator> combiner() { + return (r1, r2) -> { + r1.addAll(r2); + return r1; + }; + } + + @Override + public Function, List> finisher() { + return i -> (List) i; + } + + @Override + public Set characteristics() { + return Collections.unmodifiableSet(EnumSet.of(Characteristics.IDENTITY_FINISH)); + } + } +} 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..d1b460b9a3 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.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.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 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.orm.jpa.persistenceunit.SpringPersistenceUnitInfo; + +/** + * @author Christoph Strobl + */ +public class TestMetaModel implements Metamodel { + + private final String persistenceUnit; + private final Set> managedTypes; + private final Lazy entityManagerFactory = Lazy.of(this::init); + private final Lazy metamodel = Lazy.of(() -> entityManagerFactory.get().getMetamodel()); + private final Lazy entityManager = Lazy.of(() -> entityManagerFactory.get().createEntityManager()); + + private TestMetaModel(Set> managedTypes) { + this("dynamic-tests", managedTypes); + } + + private TestMetaModel(String persistenceUnit, Set> 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 EntityType entity(Class cls) { + return metamodel.get().entity(cls); + } + + @Override + public EntityType entity(String s) { + return metamodel.get().entity(s); + } + + public ManagedType managedType(Class cls) { + return metamodel.get().managedType(cls); + } + + public EmbeddableType embeddable(Class cls) { + return metamodel.get().embeddable(cls); + } + + public Set> getManagedTypes() { + return metamodel.get().getManagedTypes(); + } + + public Set> getEntities() { + return metamodel.get().getEntities(); + } + + public Set> getEmbeddables() { + return metamodel.get().getEmbeddables(); + } + + public EntityManager entityManager() { + return entityManager.get(); + } + + EntityManagerFactory init() { + + SpringPersistenceUnitInfo persistenceUnitInfo = new SpringPersistenceUnitInfo(this.getClass().getClassLoader()); + + persistenceUnitInfo.setPersistenceUnitName(persistenceUnit); + this.managedTypes.stream().map(Class::getName).forEach(persistenceUnitInfo::addManagedClassName); + persistenceUnitInfo.setPersistenceProviderClassName(HibernatePersistenceProvider.class.getName()); + + return new EntityManagerFactoryBuilderImpl( + new PersistenceUnitInfoDescriptor(persistenceUnitInfo.asStandardPersistenceUnitInfo()) {}, + 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 @@ - + 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 new file mode 100644 index 0000000000..a78eb59468 --- /dev/null +++ b/spring-data-jpa/src/test/resources/META-INF/persistence-jmh.xml @@ -0,0 +1,29 @@ + + + + + org.hibernate.jpa.HibernatePersistenceProvider + org.springframework.data.jpa.domain.AbstractPersistable + org.springframework.data.jpa.domain.AbstractAuditable + org.springframework.data.jpa.benchmark.model.Person + org.springframework.data.jpa.benchmark.model.Profile + true + + 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..44bbc1a702 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 @@ - + + META-INF/orm.xml org.springframework.data.jpa.domain.AbstractPersistable org.springframework.data.jpa.domain.AbstractAuditable org.springframework.data.jpa.domain.sample.AbstractAnnotatedAuditable @@ -19,13 +23,16 @@ org.springframework.data.jpa.domain.sample.ConcreteType2 org.springframework.data.jpa.domain.sample.CustomAbstractPersistable org.springframework.data.jpa.domain.sample.Customer + org.springframework.data.jpa.domain.sample.CountryConverter org.springframework.data.jpa.domain.sample.EntityWithAssignedId org.springframework.data.jpa.domain.sample.EmbeddedIdExampleEmployeePK org.springframework.data.jpa.domain.sample.EmbeddedIdExampleEmployee org.springframework.data.jpa.domain.sample.EmbeddedIdExampleDepartment + org.springframework.data.jpa.domain.sample.ReferencingEmbeddedIdExampleEmployee org.springframework.data.jpa.domain.sample.EmployeeWithName org.springframework.data.jpa.domain.sample.IdClassExampleEmployee org.springframework.data.jpa.domain.sample.IdClassExampleDepartment + org.springframework.data.jpa.domain.sample.ReferencingIdClassExampleEmployee org.springframework.data.jpa.domain.sample.Invoice org.springframework.data.jpa.domain.sample.InvoiceItem org.springframework.data.jpa.domain.sample.Item @@ -66,6 +73,7 @@ org.springframework.data.jpa.domain.sample.MailMessage org.springframework.data.jpa.domain.sample.MailSender org.springframework.data.jpa.domain.sample.MailUser + org.springframework.data.jpa.domain.sample.Role org.springframework.data.jpa.domain.sample.User org.springframework.data.jpa.domain.sample.Dummy true @@ -75,6 +83,7 @@ org.springframework.data.jpa.domain.sample.MailMessage org.springframework.data.jpa.domain.sample.MailSender org.springframework.data.jpa.domain.sample.MailUser + org.springframework.data.jpa.domain.sample.Role org.springframework.data.jpa.domain.sample.User org.springframework.data.jpa.repository.cdi.Person org.springframework.data.jpa.domain.sample.Dummy @@ -92,6 +101,7 @@ org.springframework.data.jpa.domain.sample.User + org.springframework.data.jpa.domain.sample.Role org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Merchant org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Address org.springframework.data.jpa.repository.query.QueryUtilsIntegrationTests$Employee @@ -102,6 +112,14 @@ + + org.hibernate.jpa.HibernatePersistenceProvider + true + + + + + @@ -111,6 +129,7 @@ org.springframework.data.jpa.domain.sample.MailSender org.springframework.data.jpa.domain.sample.MailUser org.springframework.data.jpa.domain.sample.User + org.springframework.data.jpa.domain.sample.Role org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithNestedIdClass org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithIdClass @@ -129,6 +148,7 @@ org.springframework.data.jpa.domain.sample.MailSender org.springframework.data.jpa.domain.sample.MailUser org.springframework.data.jpa.domain.sample.User + org.springframework.data.jpa.domain.sample.Role org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithNestedIdClass org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithIdClass @@ -146,24 +166,6 @@ - - org.apache.openjpa.persistence.PersistenceProviderImpl - org.springframework.data.jpa.domain.sample.CustomAbstractPersistable - org.springframework.data.jpa.domain.sample.MailMessage - org.springframework.data.jpa.domain.sample.MailSender - org.springframework.data.jpa.domain.sample.MailUser - org.springframework.data.jpa.domain.sample.User - org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample - org.springframework.data.jpa.domain.sample.Dummy - true - - - - - - - - org.hibernate.jpa.HibernatePersistenceProvider org.springframework.data.jpa.domain.sample.CustomAbstractPersistable @@ -171,6 +173,7 @@ org.springframework.data.jpa.domain.sample.MailSender org.springframework.data.jpa.domain.sample.MailUser org.springframework.data.jpa.domain.sample.User + org.springframework.data.jpa.domain.sample.Role org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithNestedIdClass org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithIdClass @@ -188,6 +191,7 @@ org.springframework.data.jpa.domain.sample.MailMessage org.springframework.data.jpa.domain.sample.MailSender org.springframework.data.jpa.domain.sample.MailUser + org.springframework.data.jpa.domain.sample.Role org.springframework.data.jpa.domain.sample.User org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$Sample org.springframework.data.jpa.repository.support.JpaMetamodelEntityInformationIntegrationTests$EntityWithNestedIdClass 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..9b92f05a73 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 @@ - + org.springframework.data.jpa.domain.sample.AnnotatedAuditableUser org.springframework.data.jpa.domain.sample.AuditableRole @@ -10,6 +11,7 @@ org.springframework.data.jpa.domain.sample.AuditableEmbeddable org.springframework.data.jpa.domain.sample.Book org.springframework.data.jpa.domain.sample.Category + org.springframework.data.jpa.domain.sample.Customer org.springframework.data.jpa.domain.sample.CustomAbstractPersistable org.springframework.data.jpa.domain.sample.EntityWithAssignedId org.springframework.data.jpa.domain.sample.Item @@ -31,6 +33,7 @@ org.springframework.data.jpa.domain.sample.AuditableUser org.springframework.data.jpa.domain.sample.AuditableRole org.springframework.data.jpa.domain.sample.Category + org.springframework.data.jpa.domain.sample.Customer org.springframework.data.jpa.domain.sample.CustomAbstractPersistable org.springframework.data.jpa.domain.sample.EntityWithAssignedId org.springframework.data.jpa.domain.sample.Item diff --git a/spring-data-jpa/src/test/resources/application-context.xml b/spring-data-jpa/src/test/resources/application-context.xml index 1bd58b22cd..bc6692f19c 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 @@ - - + - @@ -39,10 +37,6 @@ - - - - diff --git a/spring-data-jpa/src/test/resources/hjppa-test.xml b/spring-data-jpa/src/test/resources/hjppa-test.xml deleted file mode 100644 index cec01327c5..0000000000 --- a/spring-data-jpa/src/test/resources/hjppa-test.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - org.springframework.data.jpa.domain - org.springframework.data.jpa.domain.sample - - - - - - - - - - - - - - - - - - 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 @@ + + diff --git a/spring-data-jpa/src/test/resources/multitenancy-test.xml b/spring-data-jpa/src/test/resources/multitenancy-test.xml new file mode 100644 index 0000000000..d1ff786d12 --- /dev/null +++ b/spring-data-jpa/src/test/resources/multitenancy-test.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + org.springframework.data.jpa.repository.HibernateCurrentTenantIdentifierResolver + + + + + + + + + + + + + + + + + + + + + + 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 @@ - - - - - - - - - - none - - - - - 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 @@ - + 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 @@ - - + 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 @@ - - + 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 @@ - + foo.xml org.springframework.data.jpa.domain.sample.User 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 @@ - + bar.xml org.springframework.data.jpa.domain.sample.Role diff --git a/spring-data-jpa/src/test/resources/scripts/oracle-vector-initialize.sql b/spring-data-jpa/src/test/resources/scripts/oracle-vector-initialize.sql new file mode 100644 index 0000000000..23a69dddb7 --- /dev/null +++ b/spring-data-jpa/src/test/resources/scripts/oracle-vector-initialize.sql @@ -0,0 +1,11 @@ +-- Exit on any errors +WHENEVER SQLERROR EXIT SQL.SQLCODE + +-- Configure the size of the Vector Pool to 1 GiB. +ALTER SYSTEM SET vector_memory_size = 1G SCOPE=SPFILE; + +SHUTDOWN +ABORT; +STARTUP; + +exit; diff --git a/spring-data-jpa/src/test/resources/scripts/oracle-vector.sql b/spring-data-jpa/src/test/resources/scripts/oracle-vector.sql new file mode 100644 index 0000000000..f11fb13fc3 --- /dev/null +++ b/spring-data-jpa/src/test/resources/scripts/oracle-vector.sql @@ -0,0 +1,17 @@ +DROP TABLE IF EXISTS with_vector;; + +CREATE TABLE IF NOT EXISTS with_vector +( + id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY, + country varchar2(10), + description varchar2(10), + distance varchar2(10), + the_embedding vector(5, FLOAT32) annotations(Distance 'COSINE', IndexType 'IVF') +);; + +create +vector index if not exists vector_index_1 on with_vector (the_embedding) + organization neighbor partitions + distance COSINE +with target accuracy 95 + parameters (type IVF, neighbor partitions 10);; diff --git a/spring-data-jpa/src/test/resources/scripts/pgvector.sql b/spring-data-jpa/src/test/resources/scripts/pgvector.sql new file mode 100644 index 0000000000..b91725750d --- /dev/null +++ b/spring-data-jpa/src/test/resources/scripts/pgvector.sql @@ -0,0 +1,7 @@ +CREATE EXTENSION IF NOT EXISTS vector; + +DROP TABLE IF EXISTS with_vector; + +CREATE TABLE IF NOT EXISTS with_vector (id bigserial PRIMARY KEY,country varchar(10), description varchar(10), distance varchar(10), the_embedding vector(5)); + +CREATE INDEX ON with_vector USING hnsw (the_embedding vector_l2_ops); diff --git a/spring-data-jpa/src/test/resources/scripts/postgres-stored-procedures.sql b/spring-data-jpa/src/test/resources/scripts/postgres-stored-procedures.sql index ceafa45f00..2f5d9a7fe7 100644 --- a/spring-data-jpa/src/test/resources/scripts/postgres-stored-procedures.sql +++ b/spring-data-jpa/src/test/resources/scripts/postgres-stored-procedures.sql @@ -51,4 +51,27 @@ $BODY$ BEGIN outParam = 3; END; -$BODY$;; \ No newline at end of file +$BODY$;; + +CREATE OR REPLACE PROCEDURE multiple_out(IN someNumber integer, OUT some_cursor REFCURSOR, + OUT result1 integer, OUT result2 integer) + LANGUAGE 'plpgsql' +AS +$BODY$ +BEGIN + result1 = 1 * someNumber; + result2 = 2 * someNumber; + + OPEN some_cursor FOR SELECT COUNT(*) FROM employee; +END; +$BODY$;; + +CREATE OR REPLACE PROCEDURE accept_array(IN some_chars VARCHAR(255)[], + OUT dims VARCHAR(255)) + LANGUAGE 'plpgsql' +AS +$BODY$ +BEGIN + dims = array_dims(some_chars); +END; +$BODY$;; 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 @@ - + true diff --git a/src/main/antora/antora-playbook.yml b/src/main/antora/antora-playbook.yml index db6ae1d713..22b7c06465 100644 --- a/src/main/antora/antora-playbook.yml +++ b/src/main/antora/antora-playbook.yml @@ -3,12 +3,11 @@ # The purpose of this Antora playbook is to build the docs in the current branch. antora: extensions: - - '@antora/collector-extension' - - require: '@springio/antora-extensions/root-component-extension' + - require: '@springio/antora-extensions' root_component_name: 'data-jpa' site: title: Spring Data JPA - url: https://docs.spring.io/spring-data/jpa/reference/ + url: https://docs.spring.io/spring-data/jpa/reference content: sources: - url: ./../../.. @@ -18,17 +17,16 @@ content: - url: https://github.com/spring-projects/spring-data-commons # Refname matching: # https://docs.antora.org/antora/latest/playbook/content-refname-matching/ - branches: [main, 3.2.x] + branches: [ main ] start_path: src/main/antora asciidoc: attributes: - page-pagination: '' hide-uri-scheme: '@' tabs-sync-option: '@' - chomp: 'all' extensions: - '@asciidoctor/tabs' - '@springio/asciidoctor-extensions' + - '@springio/asciidoctor-extensions/javadoc-extension' sourcemap: true urls: latest_version_segment: '' @@ -38,5 +36,5 @@ runtime: format: pretty ui: bundle: - url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.3.3/ui-bundle.zip + url: https://github.com/spring-io/antora-ui-spring/releases/download/v0.4.16/ui-bundle.zip snapshot: true diff --git a/src/main/antora/antora.yml b/src/main/antora/antora.yml index 68f27da5f3..3a4a343f36 100644 --- a/src/main/antora/antora.yml +++ b/src/main/antora/antora.yml @@ -6,7 +6,12 @@ nav: ext: collector: - run: - command: ./mvnw validate process-resources -pl :spring-data-jpa-distribution -am -Pantora-process-resources + command: ./mvnw test-compile -Pantora-process-resources local: true scan: - dir: spring-data-jpa-distribution/target/classes/ + dir: spring-data-jpa-distribution/target/classes + - run: + command: ./mvnw package -Pdistribute + local: true + scan: + dir: target/antora diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index 5a0deada15..126f33c4af 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -9,10 +9,12 @@ ** xref:jpa/entity-persistence.adoc[] ** xref:repositories/query-methods-details.adoc[] ** xref:jpa/query-methods.adoc[] +** xref:jpa/value-expressions.adoc[] ** xref:repositories/projections.adoc[] ** xref:jpa/stored-procedures.adoc[] ** xref:jpa/specifications.adoc[] ** xref:repositories/query-by-example.adoc[] +** xref:repositories/vector-search.adoc[] ** xref:jpa/transactions.adoc[] ** xref:jpa/locking.adoc[] ** xref:auditing.adoc[] @@ -24,6 +26,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[] @@ -32,4 +35,5 @@ ** xref:envers/configuration.adoc[] ** xref:envers/usage.adoc[] -* https://github.com/spring-projects/spring-data-commons/wiki[Wiki] +* xref:attachment$api/java/index.html[Javadoc,role=link-external, window=_blank] +* https://github.com/spring-projects/spring-data-commons/wiki[Wiki,role=link-external, window=_blank] diff --git a/src/main/antora/modules/ROOT/pages/index.adoc b/src/main/antora/modules/ROOT/pages/index.adoc index 83a6f4ac77..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-2024 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..ec06946abd --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/jpa/aot.adoc @@ -0,0 +1,211 @@ += 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 `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. +When AOT is enabled (either for native compilation or by setting `spring.aot.enabled=true`), AOT repositories are automatically enabled by default. + +You can disable AOT repository generation entirely or only disable JPA AOT repositories: + +* Set the `spring.aot.repositories.enabled=false` property to disable generated repositories for all Spring Data modules. +* Set the `spring.aot.jpa.repositories.enabled=false` property to disable only JPA AOT repositories. + +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. + +NOTE: AOT processing avoids database access. +Therefore, it initializes an in-memory Hibernate instance for metadata collection. +Types for the Hibernate configuration are determined by our AOT metadata collector. +We prefer using a `PersistentEntityTypes` bean if available and fall back to `PersistenceUnitInfo` or our own discovered types. +If our type scanning is not sufficient for your arrangement, you can enable direct `EntityManagerFactory` usage by configuring the `spring.aot.jpa.repositories.use-entitymanager=true` property. + +=== 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 +* Stored procedure query methods annotated with `@Procedure` +* `@Modifying` methods returning `void` or `int` +* `@QueryHints` support +* Pagination, `Slice`, `Stream`, and `Optional` return types +* Sort query rewriting +* Interface and 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. +* `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`, Querydsl, Query by Example, and other base interface methods as their implementation is provided by the base class respective fragments +* Methods whose implementation would be overly complex +** Methods accepting `ScrollPosition` (e.g. `Keyset` pagination) +** 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 { + + List findUserNoArgumentsBy(); <1> + + Page findPageOfUsersByLastnameStartingWith(String lastname, Pageable page); <2> + + @Query("select u from User u where u.emailAddress = ?1") + User findAnnotatedQueryByEmailAddress(String emailAddress); <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": "JPA", + "type": "IMPERATIVE", + "methods": [ + { + "name": "findUserNoArgumentsBy", + "signature": "public abstract java.util.List 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.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/entity-persistence.adoc b/src/main/antora/modules/ROOT/pages/jpa/entity-persistence.adoc index 16ad24607f..ac8fcd4a75 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/entity-persistence.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/entity-persistence.adoc @@ -3,12 +3,12 @@ This section describes how to persist (save) entities with Spring Data JPA. -[[jpa.entity-persistence.saving-entites]] +[[jpa.entity-persistence.saving-entities]] == Saving Entities Saving an entity can be performed with the `CrudRepository.save(…)` method. It persists or merges the given entity by using the underlying JPA `EntityManager`. If the entity has not yet been persisted, Spring Data JPA saves the entity with a call to the `entityManager.persist(…)` method. Otherwise, it calls the `entityManager.merge(…)` method. -[[jpa.entity-persistence.saving-entites.strategies]] +[[jpa.entity-persistence.saving-entities.strategies]] === Entity State-detection Strategies Spring Data JPA offers the following strategies to detect whether an entity is new or not: @@ -18,8 +18,10 @@ Spring Data JPA offers the following strategies to detect whether an entity is n Without such a Version-property Spring Data JPA inspects the identifier property of the given entity. If the identifier property is `null`, then the entity is assumed to be new. Otherwise, it is assumed to be not new. -2. Implementing `Persistable`: If an entity implements `Persistable`, Spring Data JPA delegates the new detection to the `isNew(…)` method of the entity. See the link:$$https://docs.spring.io/spring-data/data-commons/docs/current/api/index.html?org/springframework/data/domain/Persistable.html$$[JavaDoc] for details. -3. Implementing `EntityInformation`: You can customize the `EntityInformation` abstraction used in the `SimpleJpaRepository` implementation by creating a subclass of `JpaRepositoryFactory` and overriding the `getEntityInformation(…)` method accordingly. You then have to register the custom implementation of `JpaRepositoryFactory` as a Spring bean. Note that this should be rarely necessary. See the link:$$https://docs.spring.io/spring-data/data-jpa/docs/current/api/index.html?org/springframework/data/jpa/repository/support/JpaRepositoryFactory.html$$[JavaDoc] for details. +In contrast to other Spring Data modules, JPA considers `0` (zero) as the first inserted version of an entity and therefore, a primitive version property cannot be used to determine whether an entity is new or not. +2. Implementing `Persistable`: If an entity implements `Persistable`, Spring Data JPA delegates the new detection to the `isNew(…)` method of the entity. +See the link:{spring-data-commons-javadoc-base}/org/springframework/data/domain/Persistable.html[JavaDoc] for details. +3. Implementing `EntityInformation`: You can customize the `EntityInformation` abstraction used in the `SimpleJpaRepository` implementation by creating a subclass of `JpaRepositoryFactory` and overriding the `getEntityInformation(…)` method accordingly. You then have to register the custom implementation of `JpaRepositoryFactory` as a Spring bean. Note that this should be rarely necessary. See the javadoc:org.springframework.data.jpa.repository.support.JpaRepositoryFactory[JavaDoc] for details. Option 1 is not an option for entities that use manually assigned identifiers and no version attribute as with those the identifier will always be non-`null`. A common pattern in that scenario is to use a common base class with a transient flag defaulting to indicate a new instance and using JPA lifecycle callbacks to flip that flag on persistence operations: @@ -39,7 +41,7 @@ public abstract class AbstractEntity implements Persistable { return isNew; <2> } - @PrePersist <3> + @PostPersist <3> @PostLoad void markNotNew() { this.isNew = false; diff --git a/src/main/antora/modules/ROOT/pages/jpa/jpd-misc-cdi-integration.adoc b/src/main/antora/modules/ROOT/pages/jpa/jpd-misc-cdi-integration.adoc index 98ba2bb2be..151eacb363 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/jpd-misc-cdi-integration.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/jpd-misc-cdi-integration.adoc @@ -46,7 +46,7 @@ class CdiConfig { In the preceding example, the container has to be capable of creating JPA `EntityManagers` itself. All the configuration does is re-export the JPA `EntityManager` as a CDI bean. -The Spring Data JPA CDI extension picks up all available `EntityManager` instances as CDI beans and creates a proxy for a Spring Data repository whenever a bean of a repository type is requested by the container. Thus, obtaining an instance of a Spring Data repository is a matter of declaring an `@Injected` property, as shown in the following example: +The Spring Data JPA CDI extension picks up all available `EntityManager` instances as CDI beans and creates a proxy for a Spring Data repository whenever a bean of a repository type is requested by the container. Thus, obtaining an instance of a Spring Data repository is a matter of declaring an `@Inject` property, as shown in the following example: [source, java] ---- diff --git a/src/main/antora/modules/ROOT/pages/jpa/misc-merging-persistence-units.adoc b/src/main/antora/modules/ROOT/pages/jpa/misc-merging-persistence-units.adoc index 35ef016c2f..7b2165b665 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/misc-merging-persistence-units.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/misc-merging-persistence-units.adoc @@ -37,5 +37,5 @@ A plain JPA setup requires all annotation-mapped entity classes to be listed in ---- ==== -NOTE: As of Spring 3.1, a package to scan can be configured on the `LocalContainerEntityManagerFactoryBean` directly to enable classpath scanning for entity classes. See the link:{springJavadocUrl}org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBean.html#setPackagesToScan(java.lang.String...)$$[JavaDoc] for details. +NOTE: As of Spring 3.1, a package to scan can be configured on the `LocalContainerEntityManagerFactoryBean` directly to enable classpath scanning for entity classes. See the link:{springJavadocUrl}/org/springframework/orm/jpa/LocalContainerEntityManagerFactoryBean.html#setPackagesToScan(java.lang.String...)$$[JavaDoc] for details. 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 00e4c203e3..9f8f7562b1 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc @@ -12,7 +12,7 @@ Derived queries with the predicates `IsStartingWith`, `StartingWith`, `StartsWit `IsNotContaining`, `NotContaining`, `NotContains`, `IsContaining`, `Containing`, `Contains` the respective arguments for these queries will get sanitized. This means if the arguments actually contain characters recognized by `LIKE` as wildcards these will get escaped so they match only as literals. The escape character used can be configured by setting the `escapeCharacter` of the `@EnableJpaRepositories` annotation. -Compare with xref:jpa/query-methods.adoc#jpa.query.spel-expressions[Using SpEL Expressions]. +Compare with xref:jpa/query-methods.adoc#jpa.query.spel-expressions[Using Value Expressions]. [[jpa.query-methods.declared-queries]] === Declared Queries @@ -25,13 +25,14 @@ Generally, the query creation mechanism for JPA works as described in {spring-da .Query creation from method names ==== +[source, java] ---- public interface UserRepository extends Repository { List 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: @@ -43,7 +44,7 @@ The following table describes the keywords supported for JPA and what a method c |`Distinct`|`findDistinctByLastnameAndFirstname`|`select distinct ... where x.lastname = ?1 and x.firstname = ?2` |`And`|`findByLastnameAndFirstname`|`… where x.lastname = ?1 and x.firstname = ?2` |`Or`|`findByLastnameOrFirstname`|`… where x.lastname = ?1 or x.firstname = ?2` -|`Is`, `Equals`|`findByFirstname`,`findByFirstnameIs`,`findByFirstnameEquals`|`… where x.firstname = ?1` +|`Is`, `Equals`|`findByFirstname`,`findByFirstnameIs`,`findByFirstnameEquals`|`… where x.firstname = ?1` (or `… where x.firstname IS NULL` if the argument is `null`) |`Between`|`findByStartDateBetween`|`… where x.startDate between ?1 and ?2` |`LessThan`|`findByAgeLessThan`|`… where x.age < ?1` |`LessThanEqual`|`findByAgeLessThanEqual`|`… where x.age \<= ?1` @@ -52,14 +53,14 @@ The following table describes the keywords supported for JPA and what a method c |`After`|`findByStartDateAfter`|`… where x.startDate > ?1` |`Before`|`findByStartDateBefore`|`… where x.startDate < ?1` |`IsNull`, `Null`|`findByAge(Is)Null`|`… where x.age is null` -|`IsNotNull`, `NotNull`|`findByAge(Is)NotNull`|`… where x.age not null` +|`IsNotNull`, `NotNull`|`findByAge(Is)NotNull`|`… where x.age is not null` |`Like`|`findByFirstnameLike`|`… where x.firstname like ?1` |`NotLike`|`findByFirstnameNotLike`|`… where x.firstname not like ?1` |`StartingWith`|`findByFirstnameStartingWith`|`… where x.firstname like ?1` (parameter bound with appended `%`) |`EndingWith`|`findByFirstnameEndingWith`|`… where x.firstname like ?1` (parameter bound with prepended `%`) |`Containing`|`findByFirstnameContaining`|`… where x.firstname like ?1` (parameter bound wrapped in `%`) |`OrderBy`|`findByAgeOrderByLastnameDesc`|`… where x.age = ?1 order by x.lastname desc` -|`Not`|`findByLastnameNot`|`… where x.lastname <> ?1` +|`Not`|`findByLastnameNot`|`… where x.lastname <> ?1` (or `… where x.lastname IS NOT NULL` if the argument is `null`) |`In`|`findByAgeIn(Collection ages)`|`… where x.age in ?1` |`NotIn`|`findByAgeNotIn(Collection ages)`|`… where x.age not in ?1` |`True`|`findByActiveTrue()`|`… where x.active = true` @@ -73,16 +74,16 @@ NOTE: `In` and `NotIn` also take any subclass of `Collection` as a parameter as ==== `DISTINCT` can be tricky and not always producing the results you expect. For example, `select distinct u from User u` will produce a complete different result than `select distinct u.lastname from User u`. -In the first case, since you are including `User.id`, nothing will duplicated, hence you'll get the whole table, and it would be of `User` objects. +In the first case, since you are including `User.id`, nothing will be duplicated, hence you'll get the whole table, and it would be of `User` objects. However, that latter query would narrow the focus to just `User.lastname` and find all unique last names for that table. -This would also yield a `List` result set instead of a `List result set. +This would also yield a `List` result set instead of a `List` result set. `countDistinctByLastname(String lastname)` can also produce unexpected results. Spring Data JPA will derive `select count(distinct u.id) from User u where u.lastname = ?1`. Again, since `u.id` won't hit any duplicates, this query will count up all the users that had the binding last name. -Which would the same as `countByLastname(String lastname)`! +Which would be the same as `countByLastname(String lastname)`! What is the point of this query anyway? To find the number of people with a given last name? To find the number of _distinct_ people with that binding last name? To find the number of _distinct last names_? (That last one is an entirely different query!) @@ -130,7 +131,7 @@ The query has a special name that is used to resolve it at runtime. [[jpa.query-methods.named-queries.declaring-interfaces]] === Declaring Interfaces -To allow these named queries, specify the `UserRepositoryWithRewriter` as follows: +To allow these named queries, specify the `UserRepository` as follows: .Query method declaration in UserRepository ==== @@ -169,23 +170,139 @@ public interface UserRepository extends JpaRepository { ---- ==== +[[jpa.query-methods.at-query.advanced-like]] +=== Using Advanced `LIKE` Expressions + +The query running mechanism for manually defined queries created with `@Query` allows the definition of advanced `LIKE` expressions inside the query definition, as shown in the following example: + +.Advanced `like` expressions in @Query +==== +[source, java] +---- +public interface UserRepository extends JpaRepository { + + @Query("select u from User u where u.firstname like %?1") + List findByFirstnameEndsWith(String firstname); +} +---- +==== + +In the preceding example, the `LIKE` delimiter character (`%`) is recognized, and the query is transformed into a valid JPQL query (removing the `%`). Upon running the query, the parameter passed to the method call gets augmented with the previously recognized `LIKE` pattern. + +[[jpa.query-methods.at-query.native]] +=== Native Queries + +Using the `@NativeQuery` annotation allows running native queries, as shown in the following example: + +.Declare a native query at the query method using @Query +==== +[source, java] +---- +public interface UserRepository extends JpaRepository { + + @NativeQuery(value = "SELECT * FROM USERS WHERE EMAIL_ADDRESS = ?1") + User findByEmailAddress(String emailAddress); +} +---- +==== + +NOTE: The `@NativeQuery` annotation is mostly a composed annotation for `@Query(nativeQuery=true)` but it also provides additional attributes such as `sqlResultSetMapping` to leverage JPA's `@SqlResultSetMapping(…)`. + +NOTE: Spring Data can rewrite simple queries for pagination and sorting. +More complex queries require either link:https://github.com/JSQLParser/JSqlParser[JSqlParser] to be on the class path or a `countQuery` declared in your code. +See the example below for more details. + +.Declare native count queries for pagination at the query method by using `@NativeQuery` +==== +[source, java] +---- +public interface UserRepository extends JpaRepository { + + @NativeQuery(value = "SELECT * FROM USERS WHERE LASTNAME = ?1", + countQuery = "SELECT count(*) FROM USERS WHERE LASTNAME = ?1") + Page findByLastname(String lastname, Pageable pageable); +} +---- +==== + +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 returning raw column name/value pairs +==== +[source, java] +---- +interface UserRepository extends JpaRepository { + + @NativeQuery("SELECT * FROM USERS WHERE EMAIL_ADDRESS = ?1") + Map findRawMapByEmail(String emailAddress); <1> + + @NativeQuery("SELECT * FROM USERS WHERE LASTNAME = ?1") + List> findRawMapByLastname(String lastname); <2> +} +---- +<1> Single `Map` result backed by a `Tuple`. +<2> Multiple `Map` results backed by ``Tuple``s. +==== + +NOTE: String-based Tuple Queries are only supported by Hibernate. +Eclipselink supports only Criteria-based Tuple Queries. + +[[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`. +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. +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] +[source,java] ---- public interface MyRepository extends JpaRepository { - @Query(value = "select original_user_alias.* from SD_USER original_user_alias", - nativeQuery = true, + @NativeQuery(value = "select original_user_alias.* from SD_USER original_user_alias", queryRewriter = MyQueryRewriter.class) List findByNativeQuery(String param); @@ -203,7 +320,7 @@ You can write a query rewriter like this: .Example `QueryRewriter` ==== -[source, java] +[source,java] ---- public class MyQueryRewriter implements QueryRewriter { @@ -222,7 +339,7 @@ Another option is to have the repository itself implement the interface. .Repository that provides the `QueryRewriter` ==== -[source, java] +[source,java] ---- public interface MyRepository extends JpaRepository, QueryRewriter { @@ -243,69 +360,12 @@ public interface MyRepository extends JpaRepository, QueryRewriter { ---- ==== -Depending on what you're doing with your `QueryRewriter`, it may be advisable to have more than one, each registered with the -application context. +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 - -The query running mechanism for manually defined queries created with `@Query` allows the definition of advanced `LIKE` expressions inside the query definition, as shown in the following example: - -.Advanced `like` expressions in @Query -==== -[source, java] ----- -public interface UserRepository extends JpaRepository { - - @Query("select u from User u where u.firstname like %?1") - List findByFirstnameEndsWith(String firstname); -} ----- -==== - -In the preceding example, the `LIKE` delimiter character (`%`) is recognized, and the query is transformed into a valid JPQL query (removing the `%`). Upon running the query, the parameter passed to the method call gets augmented with the previously recognized `LIKE` pattern. - -[[jpa.query-methods.at-query.native]] -=== Native Queries - -The `@Query` annotation allows for running native queries by setting the `nativeQuery` flag to true, as shown in the following example: - -.Declare a native query at the query method using @Query -==== -[source, java] ----- -public interface UserRepository extends JpaRepository { - - @Query(value = "SELECT * FROM USERS WHERE EMAIL_ADDRESS = ?1", nativeQuery = true) - User findByEmailAddress(String emailAddress); -} ----- - -==== - -NOTE: Spring Data JPA does not currently support dynamic sorting for native queries, because it would have to manipulate the actual query declared, which it cannot do reliably for native SQL. You can, however, use native queries for pagination by specifying the count query yourself, as shown in the following example: - -.Declare native count queries for pagination at the query method by using `@Query` -==== -[source, java] ----- -public interface UserRepository extends JpaRepository { - - @Query(value = "SELECT * FROM USERS WHERE LASTNAME = ?1", - countQuery = "SELECT count(*) FROM USERS WHERE LASTNAME = ?1", - nativeQuery = true) - Page findByLastname(String lastname, Pageable pageable); -} ----- - -==== - -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. - +[[jpa.query-methods.at-query.projections]] [[jpa.query-methods.sorting]] == Using Sort @@ -343,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 @@ -354,10 +425,10 @@ You have multiple options to consume large query results: You have learned in the previous chapter about `Pageable` and `PageRequest`. 2. <>. This is a lighter variant than paging because it does not require the total result count. -3. <>. +3. <>. This method avoids https://use-the-index-luke.com/no-offset[the shortcomings of offset-based result retrieval by leveraging database indexes]. -Read more on <> for your particular arrangement. +Read more on xref:repositories/query-methods-details.adoc#repositories.scrolling.guidance[which method to use best] for your particular arrangement. You can use the Scroll API with query methods, xref:repositories/query-by-example.adoc[Query-by-Example], and xref:repositories/core-extensions.adoc#core.extensions.querydsl[Querydsl]. @@ -389,13 +460,23 @@ 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 SpEL 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. -As of Spring Data JPA release 1.4, we support the usage of restricted SpEL template 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. Spring Data JPA supports a 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: If the domain type has set the name property on the `@Entity` annotation, it is used. Otherwise, the simple class-name of the domain type is used. +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 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: +* If the domain type has set the name property on the `@Entity` annotation, it is used. +* Otherwise, the simple class-name of the domain type is used. The following example demonstrates one use case for the `+#{#entityName}+` expression in a query string where you want to define a repository interface with a query method and a manually defined query: -.Using SpEL expressions in repository query methods - entityName +.Using SpEL expressions in repository query methods: entityName ==== [source, java] ---- @@ -419,13 +500,16 @@ public interface UserRepository extends JpaRepository { To avoid stating the actual entity name in the query string of a `@Query` annotation, you can use the `+#{#entityName}+` variable. -NOTE: The `entityName` can be customized by using the `@Entity` annotation. Customizations in `orm.xml` are not supported for the SpEL expressions. +NOTE: The `entityName` can be customized by using the `@Entity` annotation. +Customizations in `orm.xml` are not supported for the SpEL expressions. -Of course, you could have just used `User` in the query declaration directly, but that would require you to change the query as well. The reference to `#entityName` picks up potential future remappings of the `User` class to a different entity name (for example, by using `@Entity(name = "MyUser")`. +Of course, you could have just used `User` in the query declaration directly, but that would require you to change the query as well. +The reference to `#entityName` picks up potential future remappings of the `User` class to a different entity name (for example, by using `@Entity(name = "MyUser")`. -Another use case for the `#{#entityName}` expression in a query string is if you want to define a generic repository interface with specialized repository interfaces for a concrete domain type. To not repeat the definition of custom query methods on the concrete interfaces, you can use the entity name expression in the query string of the `@Query` annotation in the generic repository interface, as shown in the following example: +Another use case for the `#{#entityName}` expression in a query string is if you want to define a generic repository interface with specialized repository interfaces for a concrete domain type. +To not repeat the definition of custom query methods on the concrete interfaces, you can use the entity name expression in the query string of the `@Query` annotation in the generic repository interface, as shown in the following example: -.Using SpEL expressions in repository query methods - entityName with inheritance +.Using SpEL expressions in Repository Query Methods: entityName with Inheritance ==== [source, java] ---- @@ -451,13 +535,15 @@ 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`. +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 `findAllByAttribute(…)` on `ConcreteRepository`, the query becomes `select t from ConcreteType t where t.attribute = ?1`. -SpEL expressions to manipulate arguments may also be used to manipulate method arguments. -In these SpEL expressions the entity name is not available, but the arguments are. +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. They can be accessed by name or index as demonstrated in the following example. -.Using SpEL expressions in repository query methods - accessing arguments. +.Using Value Expressions in Repository Query Methods: Accessing Arguments ==== [source, java] ---- @@ -470,7 +556,7 @@ For `like`-conditions one often wants to append `%` to the beginning or the end This can be done by appending or prefixing a bind parameter marker or a SpEL expression with `%`. Again the following example demonstrates this. -.Using SpEL expressions in repository query methods - wildcard shortcut. +.Using Value Expressions in Repository Query Methods: Wildcard shortcut ==== [source, java] ---- @@ -484,8 +570,7 @@ For this purpose the `escape(String)` method is made available in the SpEL conte It prefixes all instances of `_` and `%` in the first argument with the single character from the second argument. In combination with the `escape` clause of the `like` expression available in JPQL and standard SQL this allows easy cleaning of bind parameters. - -.Using SpEL expressions in repository query methods - sanitizing input values. +.Using Value Expressions in Repository Query Methods: Sanitizing Input Values ==== [source, java] ---- @@ -499,6 +584,19 @@ The escape character used can be configured by setting the `escapeCharacter` of Note that the method `escape(String)` available in the SpEL context will only escape the SQL and JPQL standard wildcards `_` and `%`. If the underlying database or the JPA implementation supports additional wildcards these will not get escaped. +.Using Value Expressions in Repository Query Methods: Configuration Properties +==== +[source,java] +---- +@Query("select u from User u where u.applicationName = ?${spring.application.name:unknown}") +List findContainingEscaped(String namePart); +---- +==== + +You can refer in your query methods also to configuration property names including fallbacks if you wish to resolve a property from `Environment` during runtime. +The property is being evaluated upon query execution. +Typically, property placeholders resolve to String-like values. + [[jpa.query.other-methods]] == Other Methods @@ -565,6 +663,9 @@ To make sure lifecycle queries are actually invoked, an invocation of `deleteByR In fact, a derived delete query is a shortcut for running the query and then calling `CrudRepository.delete(Iterable users)` on the result and keeping behavior in sync with the implementations of other `delete(…)` methods in `CrudRepository`. +NOTE: When deleting a lot of objects you will need to consider the performance implications to ensure sufficient memory availability. +All resulting objects are loaded into memory before being deleted and are held in the session until flushing or completing the transaction. + [[jpa.query-hints]] == Applying Query Hints To apply JPA query hints to the queries declared in your repository interface, you can use the `@QueryHints` annotation. It takes an array of JPA `@QueryHint` annotations plus a boolean flag to potentially disable the hints applied to the additional count query triggered when applying pagination, as shown in the following example: @@ -760,7 +861,7 @@ public interface GroupRepository extends CrudRepository { It is also possible to define ad hoc entity graphs by using `@EntityGraph`. The provided `attributePaths` are translated into the according `EntityGraph` without needing to explicitly add `@NamedEntityGraph` to your domain types, as shown in the following example: -.Using AD-HOC entity graph definition on an repository query method. +.Using ad-hoc entity graph definitions on a repository query method ==== [source, java] ---- diff --git a/src/main/antora/modules/ROOT/pages/jpa/stored-procedures.adoc b/src/main/antora/modules/ROOT/pages/jpa/stored-procedures.adoc index 8985be4515..a285153d78 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/stored-procedures.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/stored-procedures.adoc @@ -69,7 +69,7 @@ Integer callPlus1InOut(Integer arg); ---- ==== -The following is again equivalent to the previous two but using the method name instead of an explicite annotation attribute. +The following is again equivalent to the previous two but using the method name instead of an explicit annotation attribute. .Referencing implicitly mapped named stored procedure "User.plus1" in `EntityManager` by using the method name. ==== @@ -94,4 +94,28 @@ Integer entityAnnotatedCustomNamedProcedurePlus1IO(@Param("arg") Integer arg); If the stored procedure getting called has a single out parameter that parameter may be returned as the return value of the method. If there are multiple out parameters specified in a `@NamedStoredProcedureQuery` annotation those can be returned as a `Map` with the key being the parameter name given in the `@NamedStoredProcedureQuery` annotation. +NOTE: Note that if the stored procedure returns a `ResultSet` then any `OUT` parameters are omitted as Java can only return a single method return value unless the method declares a `Map` return type. +The following example shows how to obtain multiple `OUT` parameters if the stored procedure has multiple `OUT` parameters and is registered as `@NamedStoredProcedureQuery`. `@NamedStoredProcedureQuery` registration is required to provide parameter metadata. + +.StoredProcedure metadata definitions on an entity. +==== +[source,java] +---- +@Entity +@NamedStoredProcedureQuery(name = "User.multiple_out_parameters", procedureName = "multiple_out_parameters", parameters = { + @StoredProcedureParameter(mode = ParameterMode.IN, name = "arg", type = Integer.class), + @StoredProcedureParameter(mode = ParameterMode.REF_CURSOR, name = "some_cursor", type = void.class), + @StoredProcedureParameter(mode = ParameterMode.OUT, name = "res", type = Integer.class) }) +public class User {} +---- +==== + +.Returning multiple OUT parameters +==== +[source,java] +---- +@Procedure(name = "User.multiple_out_parameters") +Map returnsMultipleOutParameters(@Param("arg") Integer arg); +---- +==== diff --git a/src/main/antora/modules/ROOT/pages/jpa/transactions.adoc b/src/main/antora/modules/ROOT/pages/jpa/transactions.adoc index f4abfccdd3..c3ca330b30 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/transactions.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/transactions.adoc @@ -1,7 +1,7 @@ [[transactions]] = Transactionality -By default, methods inherited from `CrudRepository` inherit the transactional configuration from link:$$https://docs.spring.io/spring-data/data-jpa/docs/current/api/org/springframework/data/jpa/repository/support/SimpleJpaRepository.html$$[`SimpleJpaRepository`]. +By default, methods inherited from `CrudRepository` inherit the transactional configuration from javadoc:org.springframework.data.jpa.repository.support.SimpleJpaRepository[]. For read operations, the transaction configuration `readOnly` flag is set to `true`. All others are configured with a plain `@Transactional` so that default transaction configuration applies. Repository methods that are backed by transactional repository fragments inherit the transactional attributes from the actual fragment method. @@ -89,3 +89,7 @@ Typically, you want the `readOnly` flag to be set to `true`, as most of the quer You can use transactions for read-only queries and mark them as such by setting the `readOnly` flag. Doing so does not, however, act as a check that you do not trigger a manipulating query (although some databases reject `INSERT` and `UPDATE` statements inside a read-only transaction). The `readOnly` flag is instead propagated as a hint to the underlying JDBC driver for performance optimizations. Furthermore, Spring performs some optimizations on the underlying JPA provider. For example, when used with Hibernate, the flush mode is set to `NEVER` when you configure a transaction as `readOnly`, which causes Hibernate to skip dirty checks (a noticeable improvement on large object trees). ==== +[NOTE] +==== +While examples discuss `@Transactional` usage on the repository, we generally recommend declaring transaction boundaries when starting a unit of work to ensure proper consistency and desired transaction participation. +==== diff --git a/src/main/antora/modules/ROOT/pages/jpa/value-expressions.adoc b/src/main/antora/modules/ROOT/pages/jpa/value-expressions.adoc new file mode 100644 index 0000000000..6356a46265 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/jpa/value-expressions.adoc @@ -0,0 +1 @@ +include::{commons}@data-commons::page$value-expressions.adoc[] 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 a7c2ff8d3c..754f08c357 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/core-extensions.adoc @@ -1 +1,145 @@ -include::{commons}@data-commons::page$repositories/core-extensions.adoc[] +[[core.extensions]] += Spring Data Extensions + +This section documents a set of Spring Data extensions that enable Spring Data usage in a variety of contexts. +Currently, most of the integration is targeted towards Spring MVC. + +include::{commons}@data-commons::page$repositories/core-extensions-querydsl.adoc[leveloffset=1] + +[[jpa.repositories.queries.type-safe.apt]] +=== Setting up Annotation Processing + +To use Querydsl with Spring Data JPA, you need to set up annotation processing in your build system that generates the `Q` classes. +While you could write the `Q` classes by hand, it is recommended to use the Querydsl annotation processor to generate them for you to keep your `Q` classes in sync with your domain model. + +Most Spring Data users do not use Querydsl, so it does not make sense to require additional mandatory dependencies for projects that would not benefit from Querydsl. +Hence, you need to activate annotation processing in your build system. + +The following example shows how to set up annotation processing by mentioning dependencies and compiler config changes in Maven and Gradle: + +[tabs] +====== +Maven:: ++ +[source,xml,indent=0,subs="verbatim,quotes",role="primary"] +---- + + + com.querydsl + querydsl-jpa + ${querydslVersion} + jakarta + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + com.querydsl + querydsl-apt + ${querydslVersion} + jakarta + + + jakarta.persistence + jakarta.persistence-api + + + + + target/generated-test-sources + target/generated-sources + + + + +---- + +Gradle:: ++ +==== +[source,groovy,indent=0,subs="verbatim,quotes",role="secondary"] +---- +dependencies { + + implementation 'com.querydsl:querydsl-jpa:${querydslVersion}:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:${querydslVersion}:jakarta' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + testAnnotationProcessor 'com.querydsl:querydsl-apt:${querydslVersion}:jakarta' + testAnnotationProcessor 'jakarta.persistence:jakarta.persistence-api' +} +---- +==== + +Maven (OpenFeign):: ++ +[source,xml,indent=0,subs="verbatim,quotes",role="primary"] +---- + + + io.github.openfeign.querydsl + querydsl-jpa + ${querydslVersion} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + + io.github.openfeign.querydsl + querydsl-apt + ${querydslVersion} + jpa + + + jakarta.persistence + jakarta.persistence-api + + + + target/generated-test-sources + target/generated-sources + + + + +---- + +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. + +include::{commons}@data-commons::page$repositories/core-extensions-web.adoc[leveloffset=1] + +include::{commons}@data-commons::page$repositories/core-extensions-populators.adoc[leveloffset=1] diff --git a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc index 5635695699..a9df80376a 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc @@ -1,6 +1,89 @@ [[jpa.projections]] = Projections -include::{commons}@data-commons::page$repositories/projections.adoc[leveloffset=+1] +:projection-collection: Collection -NOTE: It is important to note that <> with JPQL is limited to *constructor expressions* in your JPQL expression, e.g. `SELECT new com.example.NamesOnly(u.firstname, u.lastname) from User u`. (Note the usage of a FQDN for the DTO type!) This JPQL expression can be used in `@Query` annotations as well where you define any named queries. And it's important to point out that class-based projections do not work with native queries AT ALL. As a workaround you may use named queries with `ResultSetMapping` or the Hibernate specific https://docs.jboss.org/hibernate/orm/6.0/javadocs/org/hibernate/transform/ResultTransformer.html[`ResultTransformer`] +== Introduction + +include::{commons}@data-commons::page$repositories/projections-intro.adoc[leveloffset+=1] + +include::{commons}@data-commons::page$repositories/projections-interface.adoc[leveloffset=2] + +include::{commons}@data-commons::page$repositories/projections-class.adoc[leveloffset=2] + +== Using Projections with JPA + +You can use Projections with JPA in several ways. +Depending on the technique and query type, you need to apply specific considerations. + +Spring Data JPA uses generally `Tuple` queries to construct interface proxies for <>. + +=== Derived queries + +Query derivation supports both, class-based and interface projections by introspecting the returned type. +Class-based projections use JPA's instantiation mechanism (constructor expressions) to create the projection instance. + +Projections limit the selection to top-level properties of the target entity. +Any nested properties resolving to joins select the entire nested property causing the full join to materialize. + +=== String-based queries + +Support for string-based queries covers both, JPQL queries(`@Query`) and native queries (`@NativeQuery`). + +==== JPQL Queries + +JPA's mechanism to return <> using JPQL is *constructor expressions*. +Therefore, your query must define a constructor expression such as `SELECT new com.example.NamesOnly(u.firstname, u.lastname) from User u`. +(Note the usage of a FQDN for the DTO type!) This JPQL expression can be used in `@Query` annotations as well where you define any named queries. +As a workaround you may use named queries with `ResultSetMapping` or the Hibernate-specific javadoc:{hibernatejavadocurl}org.hibernate.query.ResultListTransformer[]. + +Spring Data JPA can aid with rewriting your query to a constructor expression if your query selects the primary entity or a list of select items. + +===== DTO Projection JPQL Query Rewriting + +JPQL queries allow selection of the root object, individual properties, and DTO objects through constructor expressions. +Using a constructor expression can quickly add a lot of text to a query and make it difficult to read the actual query. +Spring Data JPA can support you with your JPQL queries by introducing constructor expressions for your convenience. + +Consider the following queries: + +.Projection Queries +==== +[source,java] +---- +interface UserRepository extends Repository { + + @Query("SELECT u FROM USER u WHERE u.lastname = :lastname") <1> + List findByLastname(String lastname); + + @Query("SELECT u.firstname, u.lastname FROM USER u WHERE u.lastname = :lastname") <2> + List findMultipleColumnsByLastname(String lastname); +} + +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 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 WHERE u.lastname = :lastname`. +==== + +[WARNING] +==== +JPQL constructor expressions must not contain aliases for selected columns and query rewriting will not remove them for you. +While `SELECT u as user, count(u.roles) as roleCount FROM USER u …` is a valid query for interface-based projections that rely on column names from the returned `Tuple`, the same construct is invalid when requesting a DTO where it needs to be `SELECT u, count(u.roles) FROM USER u …`. + +Some persistence providers may be lenient about this, others not. +==== + +Repository query methods that return a DTO projection type (a Java type outside the domain type hierarchy) are subject for query rewriting. +If an `@Query`-annotated query already uses constructor expressions, then Spring Data backs off and doesn't apply DTO constructor expression rewriting. + +Make sure that your DTO types provide an all-args constructor for the projection, otherwise the query will fail. + +==== Native Queries + +When using <>, their usage requires slightly more consideration depending on your : + +* If properties of the result type map directly to the result (the order of columns and their types match the constructor arguments), then you can declare the query result type as the DTO type without further hints (or use the DTO class through dynamic projections). +* If the properties do not match or require transformation, use `@SqlResultSetMapping` through JPA's annotations map the result set to the DTO and provide the result mapping name through `@NativeQuery(resultSetMapping = "…")`. diff --git a/src/main/antora/modules/ROOT/pages/repositories/query-by-example.adoc b/src/main/antora/modules/ROOT/pages/repositories/query-by-example.adoc index 513ba122f0..3804f1d6c5 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/query-by-example.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/query-by-example.adoc @@ -1,3 +1,4 @@ +:support-qbe-collection: false include::{commons}@data-commons::page$query-by-example.adoc[] [[query-by-example.running]] 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/modules/ROOT/pages/repositories/vector-search.adoc b/src/main/antora/modules/ROOT/pages/repositories/vector-search.adoc new file mode 100644 index 0000000000..8821057b30 --- /dev/null +++ b/src/main/antora/modules/ROOT/pages/repositories/vector-search.adoc @@ -0,0 +1,8 @@ +:vector-search-intro-include: partial$vector-search-intro-include.adoc +:vector-search-model-include: partial$vector-search-model-include.adoc +:vector-search-repository-include: partial$vector-search-repository-include.adoc +:vector-search-scoring-include: partial$vector-search-scoring-include.adoc +:vector-search-method-derived-include: partial$vector-search-method-derived-include.adoc +:vector-search-method-annotated-include: partial$vector-search-method-annotated-include.adoc + +include::partial$vector-search.adoc[] diff --git a/src/main/antora/modules/ROOT/partials/vector-search-intro-include.adoc b/src/main/antora/modules/ROOT/partials/vector-search-intro-include.adoc new file mode 100644 index 0000000000..2c255297f4 --- /dev/null +++ b/src/main/antora/modules/ROOT/partials/vector-search-intro-include.adoc @@ -0,0 +1,32 @@ +To use Hibernate Vector Search, you need to add the following dependencies to your project. + +The following example shows how to set up dependencies in Maven and Gradle: + +[tabs] +====== +Maven:: ++ +[source,xml,indent=0,subs="verbatim,quotes",role="primary"] +---- + + + org.hibernate.orm + hibernate-vector + ${hibernate.version} + + +---- + +Gradle:: ++ +==== +[source,groovy,indent=0,subs="verbatim,quotes",role="secondary"] +---- +dependencies { + implementation 'org.hibernate.orm:hibernate-vector:${hibernateVersion}' +} +---- +==== +====== + +NOTE: While you can use `Vector` as type for queries, you cannot use it in your domain model as Hibernate requires float or double arrays as vector types. diff --git a/src/main/antora/modules/ROOT/partials/vector-search-method-annotated-include.adoc b/src/main/antora/modules/ROOT/partials/vector-search-method-annotated-include.adoc new file mode 100644 index 0000000000..851457e68d --- /dev/null +++ b/src/main/antora/modules/ROOT/partials/vector-search-method-annotated-include.adoc @@ -0,0 +1,28 @@ +Annotated search methods must define the entire JPQL query to run a Vector Search. + +.Using `@Query` Search Methods +==== +[source,java] +---- +interface CommentRepository extends Repository { + + @Query(""" + SELECT c, cosine_distance(c.embedding, :embedding) as distance FROM Comment c + WHERE c.country = ?1 + AND cosine_distance(c.embedding, :embedding) <= :distance + ORDER BY distance asc""") + SearchResults searchAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding, + Score distance); + + @Query(""" + SELECT c FROM Comment c + WHERE c.country = ?1 + AND cosine_distance(c.embedding, :embedding) <= :distance + ORDER BY cosine_distance(c.embedding, :embedding) asc""") + List findAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding, Score distance); +} +---- +==== + +Vector Search methods are not required to include a score or distance in their projection. +When using annotated search methods returning `SearchResults`, the execution mechanism assumes that if a second projection column is present that this one holds the score value. diff --git a/src/main/antora/modules/ROOT/partials/vector-search-method-derived-include.adoc b/src/main/antora/modules/ROOT/partials/vector-search-method-derived-include.adoc new file mode 100644 index 0000000000..9819837348 --- /dev/null +++ b/src/main/antora/modules/ROOT/partials/vector-search-method-derived-include.adoc @@ -0,0 +1,16 @@ +.Using `Near` and `Within` Keywords in Repository Search Methods +==== +[source,java] +---- +interface CommentRepository extends Repository { + + SearchResults searchByEmbeddingNear(Vector vector, Score score); + + SearchResults searchByEmbeddingWithin(Vector vector, Range range); + + SearchResults searchByCountryAndEmbeddingWithin(String country, Vector vector, Range range); +} +---- +==== + +Derived search methods can declare predicates on domain model attributes and Vector parameters. diff --git a/src/main/antora/modules/ROOT/partials/vector-search-model-include.adoc b/src/main/antora/modules/ROOT/partials/vector-search-model-include.adoc new file mode 100644 index 0000000000..a6966630c2 --- /dev/null +++ b/src/main/antora/modules/ROOT/partials/vector-search-model-include.adoc @@ -0,0 +1,18 @@ +==== +[source,java] +---- +class Comment { + + @Id String id; + String country; + String comment; + + @Column(name = "the_embedding") + @JdbcTypeCode(SqlTypes.VECTOR) + @Array(length = 5) + Vector embedding; + + // getters, setters, … +} +---- +==== diff --git a/src/main/antora/modules/ROOT/partials/vector-search-repository-include.adoc b/src/main/antora/modules/ROOT/partials/vector-search-repository-include.adoc new file mode 100644 index 0000000000..8955bafe89 --- /dev/null +++ b/src/main/antora/modules/ROOT/partials/vector-search-repository-include.adoc @@ -0,0 +1,21 @@ +.Using `SearchResult` in a Repository Search Method +==== +[source,java] +---- +interface CommentRepository extends Repository { + + SearchResults searchByCountryAndEmbeddingNear(String country, Vector vector, Score distance, + Limit limit); + + @Query(""" + SELECT c, cosine_distance(c.embedding, :embedding) as distance FROM Comment c + WHERE c.country = ?1 + AND cosine_distance(c.embedding, :embedding) <= :distance + ORDER BY distance asc""") + SearchResults searchAnnotatedByCountryAndEmbeddingWithin(String country, Vector embedding, + Score distance); +} + +SearchResults results = repository.searchByCountryAndEmbeddingNear("en", Vector.of(…), Score.of(0.9), Limit.of(10)); +---- +==== diff --git a/src/main/antora/modules/ROOT/partials/vector-search-scoring-include.adoc b/src/main/antora/modules/ROOT/partials/vector-search-scoring-include.adoc new file mode 100644 index 0000000000..4cd793dc91 --- /dev/null +++ b/src/main/antora/modules/ROOT/partials/vector-search-scoring-include.adoc @@ -0,0 +1,38 @@ +Hibernate translates distance function calls to native database functions for PGvector and Oracle. +Their result is typically a distance. +When using `Similarity` instead of `Score`, Spring Data normalizes distance scores into a similarity score between 0 and 1. The higher the score, the more similar the two vectors are. +// END + +.Using `Score` and `Similarity` in a Repository Search Methods +==== +[source,java] +---- +interface CommentRepository extends Repository { + + SearchResults searchByEmbeddingNear(Vector vector, ScoringFunction function); + + SearchResults searchByEmbeddingNear(Vector vector, Score score); + + SearchResults searchByEmbeddingNear(Vector vector, Similarity similarity); + + SearchResults searchByEmbeddingNear(Vector vector, Range range); +} + +repository.searchByEmbeddingNear(Vector.of(…), ScoringFunction.cosine()); <1> + +repository.searchByEmbeddingNear(Vector.of(…), Score.of(0.9, ScoringFunction.cosine())); <2> + +repository.searchByEmbeddingNear(Vector.of(…), Similarity.of(0.9, ScoringFunction.cosine())); <3> + +repository.searchByEmbeddingNear(Vector.of(…), Similarity.between(0.5, 1, ScoringFunction.euclidean()));<4> +---- + +<1> Run a search and return results that are similar to the given `Vector` applying Cosine scoring. +<2> Run a search and return results with a score of `0.9` or smaller using the Cosine distance. +<3> Run a search and normalize the score into a similarity value. +Return results with a similarity of `0.9` or greater using Cosine scoring. +<4> Run a search and normalize the score into a similarity value. +Return results with a similarity of between `0.5` and `1.0` or greater using Euclidean scoring. +==== + +NOTE: JPA requires a `ScoringFunction` to be provided when creating `Score` or `Similarity` instances to select a scoring function. diff --git a/src/main/antora/modules/ROOT/partials/vector-search.adoc b/src/main/antora/modules/ROOT/partials/vector-search.adoc new file mode 100644 index 0000000000..15e32dccee --- /dev/null +++ b/src/main/antora/modules/ROOT/partials/vector-search.adoc @@ -0,0 +1,167 @@ +[[vector-search]] += Vector Search + +With the rise of Generative AI, Vector databases have gained strong traction in the world of databases. +These databases enable efficient storage and querying of high-dimensional vectors, making them well-suited for tasks such as semantic search, recommendation systems, and natural language understanding. + +Vector search is a technique that retrieves semantically similar data by comparing vector representations (also known as embeddings) rather than relying on traditional exact-match queries. +This approach enables intelligent, context-aware applications that go beyond keyword-based retrieval. + +In the context of Spring Data, vector search opens new possibilities for building intelligent, context-aware applications, particularly in domains like natural language processing, recommendation systems, and generative AI. +By modelling vector-based querying using familiar repository abstractions, Spring Data allows developers to seamlessly integrate similarity-based vector-capable databases with the simplicity and consistency of the Spring Data programming model. + +ifdef::vector-search-intro-include[] +include::{vector-search-intro-include}[] +endif::[] + +[[vector-search.model]] +== Vector Model + +To support vector search in a type-safe and idiomatic way, Spring Data introduces the following core abstractions: + +* <> +* <` and `SearchResult`>> +* <> + +[[vector-search.model.vector]] +=== `Vector` + +The `Vector` type represents an n-dimensional numerical embedding, typically produced by embedding models. +In Spring Data, it is defined as a lightweight wrapper around an array of floating-point numbers, ensuring immutability and consistency. +This type can be used as an input for search queries or as a property on a domain entity to store the associated vector representation. + +==== +[source,java] +---- +Vector vector = Vector.of(0.23f, 0.11f, 0.77f); +---- +==== + +Using `Vector` in your domain model removes the need to work with raw arrays or lists of numbers, providing a more type-safe and expressive way to handle vector data. +This abstraction also allows for easy integration with various vector databases and libraries. +It also allows for implementing vendor-specific optimizations such as binary or quantized vectors that do not map to a standard floating point (`float` and `double` as of https://en.wikipedia.org/wiki/IEEE_754[IEEE 754]) representation. +A domain object can have a vector property, which can be used for similarity searches. +Consider the following example: + +ifdef::vector-search-model-include[] +include::{vector-search-model-include}[] +endif::[] + +NOTE: Associating a vector with a domain object results in the vector being loaded and stored as part of the entity lifecycle, which may introduce additional overhead on retrieval and persistence operations. + +[[vector-search.model.search-result]] +=== Search Results + +The `SearchResult` type encapsulates the results of a vector similarity query. +It includes both the matched domain object and a relevance score that indicates how closely it matches the query vector. +This abstraction provides a structured way to handle result ranking and enables developers to easily work with both the data and its contextual relevance. + +ifdef::vector-search-repository-include[] +include::{vector-search-repository-include}[] +endif::[] + +In this example, the `searchByCountryAndEmbeddingNear` method returns a `SearchResults` object, which contains a list of `SearchResult` instances. +Each result includes the matched `Comment` entity and its relevance score. + +Relevance score is a numerical value that indicates how closely the matched vector aligns with the query vector. +Depending on whether a score represents distance or similarity a higher score can mean a closer match or a more distant one. + +The scoring function used to calculate this score can vary based on the underlying database, index or input parameters. + +[[vector-search.model.scoring]] +=== Score, Similarity, and Scoring Functions + +The `Score` type holds a numerical value indicating the relevance of a search result. +It can be used to rank results based on their similarity to the query vector. +The `Score` type is typically a floating-point number, and its interpretation (higher is better or lower is better) depends on the specific similarity function used. +Scores are a by-product of vector search and are not required for a successful search operation. +Score values are not part of a domain model and therefore represented best as out-of-band data. + +Generally, a Score is computed by a `ScoringFunction`. +The actual scoring function used to calculate this score can depends on the underlying database and can be obtained from a search index or input parameters. + +Spring Data support declares constants for commonly used functions such as: + +Euclidean Distance:: Calculates the straight-line distance in n-dimensional space involving the square root of the sum of squared differences. +Cosine Similarity:: Measures the angle between two vectors by calculating the Dot product first and then normalizing its result by dividing by the product of their lengths. +Dot Product:: Computes the sum of element-wise multiplications. + +The choice of similarity function can impact both the performance and semantics of the search and is often determined by the underlying database or index being used. +Spring Data adopts to the database's native scoring function capabilities and whether the score can be used to limit results. + +ifdef::vector-search-scoring-include[] +include::{vector-search-scoring-include}[] +endif::[] + +[[vector-search.methods]] +== Vector Search Methods + +Vector search methods are defined in repositories using the same conventions as standard Spring Data query methods. +These methods return `SearchResults` and require a `Vector` parameter to define the query vector. +The actual implementation depends on the actual internals of the underlying data store and its capabilities around vector search. + +NOTE: If you are new to Spring Data repositories, make sure to familiarize yourself with the xref:repositories/core-concepts.adoc[basics of repository definitions and query methods]. + +Generally, you have the choice of declaring a search method using two approaches: + +* Query Derivation +* Declaring a String-based Query + +Vector Search methods must declare a `Vector` parameter to define the query vector. + +[[vector-search.method.derivation]] +=== Derived Search Methods + +A derived search method uses the name of the method to derive the query. +Vector Search supports the following keywords to run a Vector search when declaring a search method: + +.Query predicate keywords +[options="header",cols="1,3"] +|=============== +|Logical keyword|Keyword expressions +|`NEAR`|`Near`, `IsNear` +|`WITHIN`|`Within`, `IsWithin` +|=============== + +ifdef::vector-search-method-derived-include[] +include::{vector-search-method-derived-include}[] +endif::[] + +Derived search methods are typically easier to read and maintain, as they rely on the method name to express the query intent. +However, a derived search method requires either to declare a `Score`, `Range` or `ScoreFunction` as second argument to the `Near`/`Within` keyword to limit search results by their score. + +[[vector-search.method.string]] +=== Annotated Search Methods + +Annotated methods provide full control over the query semantics and parameters. +Unlike derived methods, they do not rely on method name conventions. + +ifdef::vector-search-method-annotated-include[] +include::{vector-search-method-annotated-include}[] +endif::[] + +With more control over the actual query, Spring Data can make fewer assumptions about the query and its parameters. +For example, `Similarity` normalization uses the native score function within the query to normalize the given similarity into a score predicate value and vice versa. +If an annotated query does not define e.g. the score, then the score value in the returned `SearchResult` will be zero. + +[[vector-search.method.sorting]] +=== Sorting + +By default, search results are ordered according to their score. +You can override sorting by using the `Sort` parameter: + +.Using `Sort` in Repository Search Methods +==== +[source,java] +---- +interface CommentRepository extends Repository { + + SearchResults searchByEmbeddingNearOrderByCountry(Vector vector, Score score); + + SearchResults searchByEmbeddingWithin(Vector vector, Score score, Sort sort); +} +---- +==== + +Please note that custom sorting does not allow expressing the score as a sorting criteria. +You can only refer to domain properties. diff --git a/src/main/antora/resources/antora-resources/antora.yml b/src/main/antora/resources/antora-resources/antora.yml index 4b911037b3..b0a1b58fdb 100644 --- a/src/main/antora/resources/antora-resources/antora.yml +++ b/src/main/antora/resources/antora-resources/antora.yml @@ -3,18 +3,21 @@ prerelease: ${antora-component.prerelease} asciidoc: attributes: - version: ${project.version} - springversionshort: ${spring.short} - springversion: ${spring} attribute-missing: 'warn' - commons: ${springdata.commons.docs} + chomp: 'all' + version: '${project.version}' + copyright-year: '${current.year}' + springversionshort: '${spring.short}' + springversion: '${spring}' + commons: '${springdata.commons.docs}' include-xml-namespaces: false - spring-data-commons-docs-url: https://docs.spring.io/spring-data/commons/reference - spring-data-commons-javadoc-base: https://docs.spring.io/spring-data/commons/docs/${springdata.commons}/api/ - springdocsurl: https://docs.spring.io/spring-framework/reference/{springversionshort} - springjavadocurl: https://docs.spring.io/spring-framework/docs/${spring}/javadoc-api + spring-data-commons-docs-url: '${documentation.baseurl}/spring-data/commons/reference/${springdata.commons.short}' + spring-data-commons-javadoc-base: '{spring-data-commons-docs-url}/api/java' + springdocsurl: '${documentation.baseurl}/spring-framework/reference/{springversionshort}' spring-framework-docs: '{springdocsurl}' + springjavadocurl: '${documentation.spring-javadoc-url}' spring-framework-javadoc: '{springjavadocurl}' - springhateoasversion: ${spring-hateoas} - releasetrainversion: ${releasetrain} + springhateoasversion: '${spring-hateoas}' + hibernatejavadocurl: https://docs.jboss.org/hibernate/orm/6.6/javadocs/ + releasetrainversion: '${releasetrain}' store: Jpa