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 a5f764579a..4c8108d353 100644 --- a/.github/workflows/project.yml +++ b/.github/workflows/project.yml @@ -10,6 +10,11 @@ on: pull_request_target: types: [opened, edited, reopened] +permissions: + contents: read + issues: write + pull-requests: write + jobs: Inbox: runs-on: ubuntu-latest diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index 8eb4cb3b3d..df07464f75 100755 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,2 +1,2 @@ -#Thu Nov 07 09:47:17 CET 2024 -distributionUrl=https\://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-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 915e46ddb7..2a026b08fa 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -58,50 +58,6 @@ pipeline { } parallel { - stage("test: hibernate 6.2 (LTS)") { - agent { - label 'data' - } - options { timeout(time: 30, unit: 'MINUTES')} - environment { - ARTIFACTORY = credentials("${p['artifactory.credentials']}") - DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") - TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor' - } - steps { - script { - docker.withRegistry(p['docker.proxy.registry'], p['docker.proxy.credentials']) { - docker.image(p['docker.java.next.image']).inside(p['docker.java.inside.docker']) { - sh "PROFILE=all-dbs,hibernate-62 " + - "JENKINS_USER_NAME=${p['jenkins.user.name']} " + - "ci/test.sh" - } - } - } - } - } - stage("test: baseline (hibernate 6.6 snapshots)") { - agent { - label 'data' - } - options { timeout(time: 30, unit: 'MINUTES')} - environment { - ARTIFACTORY = credentials("${p['artifactory.credentials']}") - DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") - TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor' - } - steps { - script { - docker.withRegistry(p['docker.proxy.registry'], p['docker.proxy.credentials']) { - docker.image(p['docker.java.next.image']).inside(p['docker.java.inside.docker']) { - sh "PROFILE=all-dbs,hibernate-66-snapshots " + - "JENKINS_USER_NAME=${p['jenkins.user.name']} " + - "ci/test.sh" - } - } - } - } - } stage("test: java.next (next)") { agent { label 'data' @@ -124,28 +80,6 @@ pipeline { } } } - stage("test: eclipselink-next") { - agent { - label 'data' - } - options { timeout(time: 30, unit: 'MINUTES')} - environment { - ARTIFACTORY = credentials("${p['artifactory.credentials']}") - DEVELOCITY_ACCESS_KEY = credentials("${p['develocity.access-key']}") - TESTCONTAINERS_IMAGE_SUBSTITUTOR = 'org.springframework.data.jpa.support.ProxyImageNameSubstitutor' - } - steps { - script { - docker.withRegistry(p['docker.proxy.registry'], p['docker.proxy.credentials']) { - docker.image(p['docker.java.main.image']).inside(p['docker.java.inside.docker']) { - sh "PROFILE=all-dbs,eclipselink-next " + - "JENKINS_USER_NAME=${p['jenkins.user.name']} " + - "ci/test.sh" - } - } - } - } - } } } diff --git a/README.adoc b/README.adoc index 3c5597973a..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/] 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. @@ -143,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 @@ -157,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 @@ -168,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 8dd2295acc..ed898052b1 100644 --- a/ci/pipeline.properties +++ b/ci/pipeline.properties @@ -1,6 +1,6 @@ # Java versions -java.main.tag=17.0.15_6-jdk-focal -java.next.tag=24.0.1_9-jdk-noble +java.main.tag=25-jdk-noble +java.next.tag=25-jdk-noble # Docker container images - standard docker.java.main.image=library/eclipse-temurin:${java.main.tag} 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 e5fefdec28..6fd19eccb9 100755 --- a/pom.xml +++ b/pom.xml @@ -1,11 +1,11 @@ - + 4.0.0 org.springframework.data spring-data-jpa-parent - 3.5.0 + 4.0.0-SNAPSHOT pom Spring Data JPA Parent @@ -23,25 +23,23 @@ org.springframework.data.build spring-data-parent - 3.5.0 + 4.0.0-SNAPSHOT - 4.13.0 - 4.0.6 - 4.0.7-SNAPSHOT - 6.6.15.Final - 6.2.36.Final - 6.6.16-SNAPSHOT - 7.0.0.Beta5 - 7.0.0-SNAPSHOT + 4.13.2 + 5.0.0-B10 + 5.0.0-SNAPSHOT + 7.1.1.Final + 7.1.2-SNAPSHOT 2.7.4

2.3.232

- 3.1.0 - 5.2 + 3.2.0 + 5.3 9.2.0 - 42.7.5 - 3.5.0 + 42.7.7 + 23.8.0.25.04 + 4.0.0-SNAPSHOT 0.10.3 org.hibernate @@ -58,32 +56,19 @@ - hibernate-62 - - ${hibernate-62} - - - - hibernate-66-snapshots - - ${hibernate-66-snapshots} - + jmh - sonatype-oss - https://oss.sonatype.org/content/repositories/snapshots - - false - + jitpack + https://jitpack.io - hibernate-70 + hibernate-71-snapshots - ${hibernate-70} + ${hibernate-71-snapshots} 3.2.0 - 4.13.2 @@ -96,20 +81,55 @@ - hibernate-70-snapshots - - ${hibernate-70-snapshots} - 3.2.0 - - - - sonatype-oss - https://oss.sonatype.org/content/repositories/snapshots - - false - - - + all-dbs + + + + org.apache.maven.plugins + maven-surefire-plugin + + + mysql-test + test + + test + + + + **/MySql*IntegrationTests.java + + + + + postgres-test + test + + test + + + + **/Postgres*IntegrationTests.java + + + + + + oracle-test + test + + test + + + + **/Oracle*IntegrationTests.java + + + + + + + + eclipselink-next @@ -173,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 40c50b7016..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.5.0 + 4.0.0-SNAPSHOT org.springframework.data spring-data-jpa-parent - 3.5.0 + 4.0.0-SNAPSHOT ../pom.xml 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 d8b96b28d9..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 @@ -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/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/EnversRevisionRepositoryFactoryBean.java b/spring-data-envers/src/main/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryFactoryBean.java index 9f40559ca4..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 @@ -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 4515a74ed9..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 @@ -66,12 +66,14 @@ * @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; /** @@ -90,14 +92,17 @@ public EnversRevisionRepositoryImpl(JpaEntityInformation entityInformation 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(); @@ -131,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) { @@ -171,6 +177,7 @@ private List mapPropertySort(Sort sort) { return result; } + @Override @SuppressWarnings("unchecked") public Page> findRevisions(ID id, Pageable pageable) { @@ -182,9 +189,12 @@ public Page> findRevisions(ID id, Pageable pageable) { 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) // @@ -213,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 631dbca9f8..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 @@ -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 c8c1aa963e..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 @@ -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; 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/EnversRevisionRepositoryImplUnitTests.java b/spring-data-envers/src/test/java/org/springframework/data/envers/repository/support/EnversRevisionRepositoryImplUnitTests.java index 625e099d5a..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 @@ -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/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 43f159b633..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 @@ -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-jpa-distribution/pom.xml b/spring-data-jpa-distribution/pom.xml index 9f3f2ad5c4..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.5.0 + 4.0.0-SNAPSHOT ../pom.xml diff --git a/spring-data-jpa/pom.xml b/spring-data-jpa/pom.xml index 2628916dc8..cbec8a2645 100644 --- a/spring-data-jpa/pom.xml +++ b/spring-data-jpa/pom.xml @@ -7,7 +7,7 @@ org.springframework.data spring-data-jpa - 3.5.0 + 4.0.0-SNAPSHOT Spring Data JPA Spring Data module for JPA repositories. @@ -16,7 +16,7 @@ org.springframework.data spring-data-jpa-parent - 3.5.0 + 4.0.0-SNAPSHOT ../pom.xml @@ -73,12 +73,6 @@ org.springframework spring-core - - - commons-logging - commons-logging - - @@ -94,12 +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 @@ -148,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 @@ -170,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 @@ -296,10 +358,10 @@ **/*UnitTests.java - **/OpenJpa* **/EclipseLink* **/MySql* **/Postgres* + **/Oracle* -Xmx4G @@ -350,7 +412,7 @@ org.apache.maven.plugins maven-compiler-plugin - + com.querydsl querydsl-apt @@ -377,6 +439,11 @@ jakarta.persistence-api ${jakarta-persistence-api} + + org.jboss.logging + jboss-logging + 3.6.1.Final + @@ -431,45 +498,4 @@ - - - all-dbs - - - - org.apache.maven.plugins - maven-surefire-plugin - - - mysql-test - test - - test - - - - **/MySql*IntegrationTests.java - - - - - postgres-test - test - - test - - - - **/Postgres*IntegrationTests.java - - - - - - - - - - -
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/RepositoryFinderBenchmarks.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryQueryMethodBenchmarks.java similarity index 89% rename from spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryFinderBenchmarks.java rename to spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryQueryMethodBenchmarks.java index 209dc55318..0f20652d65 100644 --- a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryFinderBenchmarks.java +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/RepositoryQueryMethodBenchmarks.java @@ -41,8 +41,8 @@ import org.openjdk.jmh.annotations.Warmup; import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.benchmark.model.IPersonProjection; import org.springframework.data.jpa.benchmark.model.Person; +import org.springframework.data.jpa.benchmark.model.PersonDto; import org.springframework.data.jpa.benchmark.model.Profile; import org.springframework.data.jpa.benchmark.repository.PersonRepository; import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; @@ -52,13 +52,14 @@ /** * @author Christoph Strobl + * @author Mark Paluch */ @Testable @Fork(1) -@Warmup(time = 2, iterations = 3) -@Measurement(time = 2) +@Warmup(time = 1, iterations = 3) +@Measurement(time = 1, iterations = 3) @Timeout(time = 2) -public class RepositoryFinderBenchmarks { +public class RepositoryQueryMethodBenchmarks { private static final String PERSON_FIRSTNAME = "first"; private static final String COLUMN_PERSON_FIRSTNAME = "firstname"; @@ -125,10 +126,16 @@ private void createEntityManager() { entityManager = entityManagerFactory.createEntityManager(); } - PersonRepository createRepository() { + public PersonRepository createRepository() { JpaRepositoryFactory repositoryFactory = new JpaRepositoryFactory(entityManager); return repositoryFactory.getRepository(PersonRepository.class); } + + } + + protected PersonRepository doCreateRepository(EntityManager entityManager) { + JpaRepositoryFactory repositoryFactory = new JpaRepositoryFactory(entityManager); + return repositoryFactory.getRepository(PersonRepository.class); } @Benchmark @@ -173,10 +180,10 @@ public List derivedFinderMethod(BenchmarkParameters parameters) { return parameters.repositoryProxy.findAllByFirstname(PERSON_FIRSTNAME); } - @Benchmark + /*@Benchmark public List derivedFinderMethodWithInterfaceProjection(BenchmarkParameters parameters) { return parameters.repositoryProxy.findAllAndProjectToInterfaceByFirstname(PERSON_FIRSTNAME); - } + } */ @Benchmark public List stringBasedQuery(BenchmarkParameters parameters) { @@ -185,7 +192,14 @@ public List stringBasedQuery(BenchmarkParameters parameters) { @Benchmark public List stringBasedQueryDynamicSort(BenchmarkParameters parameters) { - return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME, Sort.by(COLUMN_PERSON_FIRSTNAME)); + return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME, + Sort.by(COLUMN_PERSON_FIRSTNAME)); + } + + @Benchmark + public List stringBasedQueryDynamicSortAndProjection(BenchmarkParameters parameters) { + return parameters.repositoryProxy.findAllWithAnnotatedQueryByFirstname(PERSON_FIRSTNAME, + Sort.by(COLUMN_PERSON_FIRSTNAME), PersonDto.class); } @Benchmark diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaQueryUtilsIntegrationTests.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/PersonDto.java similarity index 64% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaQueryUtilsIntegrationTests.java rename to spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/PersonDto.java index fd8f1cb634..6241e6a439 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaQueryUtilsIntegrationTests.java +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/model/PersonDto.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,14 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.jpa.repository.query; - -import org.springframework.test.context.ContextConfiguration; +package org.springframework.data.jpa.benchmark.model; /** - * @author Oliver Gierke + * @author Mark Paluch */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaQueryUtilsIntegrationTests extends QueryUtilsIntegrationTests { - +public record PersonDto(String firstname, String lastname) { } diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/repository/PersonRepository.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/repository/PersonRepository.java index 491ab736a8..81950ab3fa 100644 --- a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/repository/PersonRepository.java +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/benchmark/repository/PersonRepository.java @@ -38,6 +38,9 @@ public interface PersonRepository extends ListCrudRepository { @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); diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/HqlParserBenchmarks.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/HqlParserBenchmarks.java index fd46a3f6c2..d1465ed1bc 100644 --- a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/HqlParserBenchmarks.java +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/HqlParserBenchmarks.java @@ -27,6 +27,8 @@ import org.openjdk.jmh.annotations.Warmup; import org.springframework.data.domain.Sort; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.query.ReturnedType; /** * @author Mark Paluch @@ -44,6 +46,7 @@ public static class BenchmarkParameters { DeclaredQuery query; Sort sort = Sort.by("foo"); QueryEnhancer enhancer; + QueryEnhancer.QueryRewriteInformation rewriteInformation; @Setup(Level.Iteration) public void doSetup() { @@ -55,14 +58,16 @@ OR TREAT(p AS SmallProject).name LIKE 'Persist%' OR p.description LIKE "cost overrun" """; - query = DeclaredQuery.of(s, false); - enhancer = QueryEnhancerFactory.forQuery(query); + query = DeclaredQuery.jpqlQuery(s); + enhancer = QueryEnhancerFactory.forQuery(query).create(query); + rewriteInformation = new DefaultQueryRewriteInformation(sort, + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())); } } @Benchmark public Object measure(BenchmarkParameters parameters) { - return parameters.enhancer.applySorting(parameters.sort); + return parameters.enhancer.rewrite(parameters.rewriteInformation); } } diff --git a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerBenchmarks.java b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerBenchmarks.java index 845282e319..f4121c28ed 100644 --- a/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerBenchmarks.java +++ b/spring-data-jpa/src/jmh/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerBenchmarks.java @@ -29,6 +29,8 @@ import org.openjdk.jmh.annotations.Warmup; import org.springframework.data.domain.Sort; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.query.ReturnedType; /** * @author Mark Paluch @@ -46,6 +48,7 @@ public static class BenchmarkParameters { JSqlParserQueryEnhancer enhancer; Sort sort = Sort.by("foo"); private byte[] serialized; + private QueryEnhancer.QueryRewriteInformation rewriteInformation; @Setup(Level.Iteration) public void doSetup() throws IOException { @@ -56,13 +59,15 @@ public void doSetup() throws IOException { select SOME_COLUMN from SOME_OTHER_TABLE where REPORTING_DATE = :REPORTING_DATE union select SOME_COLUMN from SOME_OTHER_OTHER_TABLE"""; - enhancer = new JSqlParserQueryEnhancer(DeclaredQuery.of(s, true)); + enhancer = new JSqlParserQueryEnhancer(DeclaredQuery.nativeQuery(s)); + rewriteInformation = new DefaultQueryRewriteInformation(sort, + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())); } } @Benchmark public Object applySortWithParsing(BenchmarkParameters p) { - return p.enhancer.applySorting(p.sort); + return p.enhancer.rewrite(p.rewriteInformation); } } diff --git a/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Eql.g4 b/spring-data-jpa/src/main/antlr4/org/springframework/data/jpa/repository/query/Eql.g4 index 0c5468091a..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 ; @@ -458,6 +464,7 @@ string_expression | string_cast_function | type_cast_function | '(' subquery ')' + | string_expression '||' string_expression ; datetime_expression @@ -542,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 @@ -583,10 +593,7 @@ datetime_part ; function_arg - : literal - | state_valued_path_expression - | input_parameter - | scalar_expression + : simple_select_expression ; case_expression @@ -625,6 +632,14 @@ nullif_expression : NULLIF '(' scalar_expression ',' scalar_expression ')' ; +type_literal + : STRING + | INTEGER + | LONG + | FLOAT + | DOUBLE + ; + /******************* Gaps in the spec. *******************/ @@ -637,6 +652,7 @@ trim_character identification_variable : IDENTIFICATION_VARIABLE | f=(COUNT + | AS | DATE | FROM | INNER @@ -646,11 +662,13 @@ identification_variable | ORDER | OUTER | POWER + | RIGHT | FLOOR | SIGN | TIME | TYPE | VALUE) + | type_literal ; constructor_name @@ -659,6 +677,7 @@ constructor_name literal : STRINGLITERAL + | JAVASTRINGLITERAL | INTLITERAL | FLOATLITERAL | LONGLITERAL @@ -833,6 +852,8 @@ reserved_word |ORDER |OUTER |POWER + |REPLACE + |RIGHT |ROUND |SELECT |SET @@ -859,6 +880,7 @@ reserved_word WS : [ \t\r\n] -> channel(HIDDEN) ; +COMMENT : '/*' (~'*' | '*' ~'/' )* '*/' -> skip; // Build up case-insentive tokens @@ -928,8 +950,8 @@ 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; -FLOOR : F L O O R; 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; GROUP : G R O U P; @@ -937,9 +959,9 @@ 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; -INTEGER : I N T E G E R; JOIN : J O I N; KEY : K E Y; LAST : L A S T; @@ -969,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; @@ -976,8 +1000,8 @@ SIGN : S I G N; SIZE : S I Z E; SOME : S O M E; SQRT : S Q R T; -SUBSTRING : S U B S T R I N G; 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; TIME : T I M E; @@ -996,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 4ed7a44554..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 @@ -112,8 +112,9 @@ joinSpecifier ; fromRoot - : entityName variable? - | LATERAL? '(' subquery ')' variable? + : entityName variable? # RootEntity + | LATERAL? '(' subquery ')' variable? # RootSubquery + | setReturningFunction variable? # RootFunction ; join @@ -121,8 +122,9 @@ join ; 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 @@ -755,9 +757,21 @@ function | 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 * @@ -1238,6 +1252,179 @@ frameExclusion | EXCLUDE NO OTHERS ; +// JSON Functions + +jsonFunction + : jsonArrayFunction + | jsonExistsFunction + | jsonObjectFunction + | jsonQueryFunction + | jsonValueFunction + | jsonArrayAggFunction + | jsonObjectAggFunction + ; + +/** + * 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 + ; + +/** + * The 'json_object( foo, bar, KEY foo VALUE bar, foo:bar ABSENT ON NULL)' function + */ +jsonObjectFunction + : JSON_OBJECT '(' jsonObjectFunctionEntry? (',' jsonObjectFunctionEntry)* jsonNullClause? ')'; + +jsonObjectFunctionEntry + : (expressionOrPredicate|jsonObjectKeyValueEntry|jsonObjectAssignmentEntry); + +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 + ; + +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? ')' + ; + +jsonValueReturningClause + : RETURNING castTarget + ; + +jsonValueOnErrorOrEmptyClause + : (ERROR | NULL | DEFAULT expression) ON (ERROR | EMPTY) + ; + +/** + * 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)* + ; + +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 + ; + +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 @@ -1372,9 +1559,11 @@ entityName nakedIdentifier : IDENTIFIER | QUOTED_IDENTIFIER - | f=(ALL + | f=(ABSENT + | ALL | AND | ANY + | ARRAY | AS | ASC | AVG @@ -1386,6 +1575,8 @@ nakedIdentifier | CAST | COLLATE | COLUMN + | COLUMNS + | CONDITIONAL | CONFLICT | CONSTRAINT | CONTAINS @@ -1448,6 +1639,15 @@ nakedIdentifier | INTO | IS | JOIN + | JSON + | JSON_ARRAY + | JSON_ARRAYAGG + | JSON_EXISTS + | JSON_OBJECT + | JSON_OBJECTAGG + | JSON_QUERY + | JSON_TABLE + | JSON_VALUE | KEY | KEYS | LAST @@ -1474,9 +1674,11 @@ nakedIdentifier | MININDEX | MINUTE | MONTH + | NAME | NANOSECOND | NATURALID | NEW + | NESTED | NEXT | NO | NOT @@ -1490,12 +1692,15 @@ nakedIdentifier | ONLY | OR | ORDER + | ORDINALITY | OTHERS | OVER | OVERFLOW | OVERLAY | PAD + | PATH | PARTITION + | PASSING | PERCENT | PLACING | POSITION @@ -1503,6 +1708,7 @@ nakedIdentifier | QUARTER | RANGE | RESPECT + | RETURNING | RIGHT | ROLLUP | ROW @@ -1529,7 +1735,9 @@ nakedIdentifier | TRUNCATE | TYPE | UNBOUNDED + | UNCONDITIONAL | UNION + | UNIQUE | UPDATE | USING | VALUE @@ -1542,6 +1750,16 @@ nakedIdentifier | WITH | WITHIN | WITHOUT + | WRAPPER + | XML + | XMLAGG + | XMLATTRIBUTES + | XMLELEMENT + | XMLEXISTS + | XMLFOREST + | XMLPI + | XMLQUERY + | XMLTABLE | YEAR | ZONED) ; @@ -1561,6 +1779,7 @@ identifier WS : [ \t\r\n] -> channel(HIDDEN); +COMMENT : '/*' (~'*' | '*' ~'/' )* '*/' -> skip; // Build up case-insentive tokens @@ -1600,9 +1819,11 @@ 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; AVG : A V G; @@ -1614,6 +1835,8 @@ CASE : C A S E; CAST : C A S T; 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; @@ -1676,6 +1899,15 @@ 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; @@ -1703,8 +1935,10 @@ 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; NEW : N E W; +NESTED : N E S T E D; NEXT : N E X T; NO : N O; NOT : N O T; @@ -1718,13 +1952,16 @@ 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; @@ -1732,6 +1969,7 @@ 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; @@ -1758,7 +1996,9 @@ 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; @@ -1769,6 +2009,16 @@ 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; 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 e0e2a0a35c..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,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 | general_identification_variable | result_variable ) (ASC | DESC)? nullsPrecedence? + : orderby_expression (ASC | DESC)? nullsPrecedence? + ; + +orderby_expression + : state_field_path_expression + | general_identification_variable + | string_expression + | scalar_expression ; nullsPrecedence @@ -253,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 ; @@ -446,6 +464,7 @@ string_expression | string_cast_function | type_cast_function | '(' subquery ')' + | string_expression '||' string_expression ; datetime_expression @@ -530,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 @@ -571,10 +593,7 @@ datetime_part ; function_arg - : literal - | state_valued_path_expression - | input_parameter - | scalar_expression + : simple_select_expression ; case_expression @@ -613,6 +632,7 @@ nullif_expression : NULLIF '(' scalar_expression ',' scalar_expression ')' ; + /******************* Gaps in the spec. *******************/ @@ -625,6 +645,7 @@ trim_character identification_variable : IDENTIFICATION_VARIABLE | f=(COUNT + | AS | DATE | FROM | INNER @@ -634,11 +655,13 @@ identification_variable | ORDER | OUTER | POWER + | RIGHT | FLOOR | SIGN | TIME | TYPE | VALUE) + | type_literal ; constructor_name @@ -666,6 +689,9 @@ pattern_value date_time_timestamp_literal : STRINGLITERAL + | DATELITERAL + | TIMELITERAL + | TIMESTAMPLITERAL ; entity_type_literal @@ -684,6 +710,14 @@ numeric_literal | LONGLITERAL ; +type_literal + : STRING + | INTEGER + | LONG + | FLOAT + | DOUBLE + ; + boolean_literal : TRUE | FALSE @@ -820,6 +854,8 @@ reserved_word |ORDER |OUTER |POWER + |REPLACE + |RIGHT |ROUND |SELECT |SET @@ -846,6 +882,7 @@ reserved_word WS : [ \t\r\n] -> channel(HIDDEN) ; +COMMENT : '/*' (~'*' | '*' ~'/' )* '*/' -> skip; // Build up case-insentive tokens @@ -908,14 +945,15 @@ 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; -FLOOR : F L O O R; 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; GROUP : G R O U P; @@ -923,9 +961,9 @@ 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; -INTEGER : I N T E G E R; JOIN : J O I N; KEY : K E Y; LAST : L A S T; @@ -955,6 +993,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; @@ -962,8 +1002,8 @@ SIGN : S I G N; SIZE : S I Z E; SOME : S O M E; SQRT : S Q R T; -SUBSTRING : S U B S T R I N G; 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; TIME : T I M E; @@ -972,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; @@ -987,4 +1028,7 @@ STRINGLITERAL : '\'' (~ ('\'' | '\\')|'\\')* '\'' ; JAVASTRINGLITERAL : '"' ( ('\\' [btnfr"']) | ~('"'))* '"'; FLOATLITERAL : ('0' .. '9')* '.' ('0' .. '9')+ (E ('0' .. '9')+)* (F|D)?; INTLITERAL : ('0' .. '9')+ ; -LONGLITERAL : ('0' .. '9')+L ; +LONGLITERAL : ('0' .. '9')+ L; +DATELITERAL : '{' D STRINGLITERAL '}'; +TIMELITERAL : '{' T STRINGLITERAL '}'; +TIMESTAMPLITERAL : '{' T S STRINGLITERAL '}'; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/QueryByExamplePredicateBuilder.java index 6b2314a2d0..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 @@ -34,6 +34,8 @@ import java.util.Set; import org.springframework.dao.InvalidDataAccessApiUsageException; + +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Example; import org.springframework.data.domain.ExampleMatcher; import org.springframework.data.domain.ExampleMatcher.MatchMode; @@ -41,7 +43,6 @@ 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; @@ -81,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); } @@ -95,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"); @@ -243,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) { @@ -255,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 87aeb9353e..12f29500eb 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConverters.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConverters.java @@ -27,6 +27,9 @@ import java.util.Date; import org.springframework.data.convert.Jsr310Converters.DateToLocalDateConverter; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; import org.springframework.data.convert.Jsr310Converters.DateToLocalDateTimeConverter; import org.springframework.data.convert.Jsr310Converters.DateToLocalTimeConverter; import org.springframework.data.convert.Jsr310Converters.LocalDateTimeToDateConverter; @@ -36,8 +39,6 @@ import org.springframework.data.convert.Jsr310Converters.ZoneIdToStringConverter; import org.springframework.data.convert.ReadingConverter; import org.springframework.data.convert.WritingConverter; -import org.springframework.lang.NonNull; -import org.springframework.lang.Nullable; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; /** @@ -53,81 +54,71 @@ public class Jsr310JpaConverters { @Converter(autoApply = true) - public static class LocalDateConverter implements AttributeConverter { + 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 c2653a2e89..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 @@ -17,17 +17,16 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.Temporal; -import jakarta.persistence.TemporalType; import java.io.Serializable; +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.Date; import java.util.Optional; import org.springframework.data.domain.Auditable; -import org.springframework.lang.Nullable; + +import org.jspecify.annotations.Nullable; /** * Abstract base class for auditable entities. Stores the audition values in persistent fields. @@ -39,20 +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 { @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() { @@ -60,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 @@ -81,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 989eac2cff..19153d70c5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractPersistable.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/AbstractPersistable.java @@ -18,13 +18,14 @@ import java.io.Serializable; import jakarta.persistence.GeneratedValue; + +import org.jspecify.annotations.Nullable; import jakarta.persistence.Id; import jakarta.persistence.MappedSuperclass; import jakarta.persistence.Transient; import org.springframework.data.domain.Persistable; import org.springframework.data.util.ProxyUtils; -import org.springframework.lang.Nullable; /** * Abstract base class for entities. Allows parameterization of id type, chooses auto-generation and implements @@ -38,11 +39,12 @@ * @param 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; @@ -74,7 +76,7 @@ public String toString() { } @Override - public boolean equals(Object obj) { + public boolean equals(@Nullable Object obj) { if (null == obj) { return false; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/DeleteSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/DeleteSpecification.java new file mode 100644 index 0000000000..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 771b5361a6..2f55c0bf03 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/JpaSort.java @@ -18,10 +18,6 @@ import jakarta.persistence.metamodel.Attribute; import jakarta.persistence.metamodel.PluralAttribute; -import org.springframework.data.domain.Sort; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; - import java.io.Serial; import java.util.ArrayList; import java.util.Arrays; @@ -29,8 +25,18 @@ import java.util.Collections; import java.util.List; +import org.springframework.data.domain.Sort; + +import org.jspecify.annotations.Nullable; +import org.springframework.lang.CheckReturnValue; +import org.springframework.lang.Contract; +import org.springframework.util.Assert; + /** - * Sort option for queries that wraps JPA meta-model {@link Attribute}s for sorting. + * Sort option for queries that wraps JPA metamodel {@link Attribute}s for sorting. + *

+ * {@link JpaSort#unsafe} accepts unsafe sort expressions, i. e. the String provided is not necessarily a property but + * can be an arbitrary expression piped into the query execution. * * @author Thomas Darimont * @author Oliver Gierke @@ -44,7 +50,7 @@ public class JpaSort extends Sort { @Serial private static final long serialVersionUID = 1L; private JpaSort(Direction direction, List> paths) { - this(Collections.emptyList(), direction, paths); + this(Collections. emptyList(), direction, paths); } private JpaSort(List orders, @Nullable Direction direction, List> paths) { @@ -76,7 +82,7 @@ public static JpaSort of(JpaSort.Path... paths) { /** * Creates a new {@link JpaSort} for the given direction and attributes. * - * @param direction the sorting direction. + * @param direction the sorting direction. * @param attributes must not be {@literal null} or empty. */ public static JpaSort of(Direction direction, Attribute... attributes) { @@ -87,7 +93,7 @@ public static JpaSort of(Direction direction, Attribute... attributes) { * Creates a new {@link JpaSort} for the given direction and {@link Path}s. * * @param direction the sorting direction. - * @param paths must not be {@literal null} or empty. + * @param paths must not be {@literal null} or empty. */ public static JpaSort of(Direction direction, Path... paths) { return new JpaSort(direction, Arrays.asList(paths)); @@ -96,10 +102,12 @@ public static JpaSort of(Direction direction, Path... paths) { /** * Returns a new {@link JpaSort} with the given sorting criteria added to the current one. * - * @param direction can be {@literal null}. + * @param direction can be {@literal null}. * @param attributes must not be {@literal null}. * @return */ + @Contract("_, _ -> new") + @CheckReturnValue public JpaSort and(@Nullable Direction direction, Attribute... attributes) { Assert.notNull(attributes, "Attributes must not be null"); @@ -111,9 +119,11 @@ public JpaSort and(@Nullable Direction direction, Attribute... attributes) * Returns a new {@link JpaSort} with the given sorting criteria added to the current one. * * @param direction can be {@literal null}. - * @param paths must not be {@literal null}. + * @param paths must not be {@literal null}. * @return */ + @Contract("_, _ -> new") + @CheckReturnValue public JpaSort and(@Nullable Direction direction, Path... paths) { Assert.notNull(paths, "Paths must not be null"); @@ -130,10 +140,12 @@ public JpaSort and(@Nullable Direction direction, Path... paths) { /** * Returns a new {@link JpaSort} with the given sorting criteria added to the current one. * - * @param direction can be {@literal null}. + * @param direction can be {@literal null}. * @param properties must not be {@literal null} or empty. * @return */ + @Contract("_, _ -> new") + @CheckReturnValue public JpaSort andUnsafe(@Nullable Direction direction, String... properties) { Assert.notEmpty(properties, "Properties must not be empty"); @@ -148,7 +160,7 @@ public JpaSort andUnsafe(@Nullable Direction direction, String... properties) { orders.add(new JpaOrder(direction, property)); } - return new JpaSort(orders, direction, Collections.>emptyList()); + return new JpaSort(orders, direction, Collections.> emptyList()); } /** @@ -219,7 +231,7 @@ public static JpaSort unsafe(String... properties) { /** * Creates new unsafe {@link JpaSort} based on given {@link Direction} and properties. * - * @param direction must not be {@literal null}. + * @param direction must not be {@literal null}. * @param properties must not be {@literal null} or empty. * @return */ @@ -235,7 +247,7 @@ public static JpaSort unsafe(Direction direction, String... properties) { /** * Creates new unsafe {@link JpaSort} based on given {@link Direction} and properties. * - * @param direction must not be {@literal null}. + * @param direction must not be {@literal null}. * @param properties must not be {@literal null} or empty. * @return */ @@ -271,6 +283,8 @@ private Path(List> attributes) { * @param attribute must not be {@literal null}. * @return */ + @Contract("_ -> new") + @CheckReturnValue public , U> Path dot(A attribute) { return new Path<>(add(attribute)); } @@ -281,6 +295,8 @@ 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)); } @@ -327,7 +343,7 @@ public static class JpaOrder extends Order { * {@link Sort#DEFAULT_DIRECTION} * * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}. - * @param property must not be {@literal null}. + * @param property must not be {@literal null}. */ private JpaOrder(@Nullable Direction direction, String property) { this(direction, property, NullHandling.NATIVE); @@ -337,8 +353,8 @@ private JpaOrder(@Nullable Direction direction, String property) { * Creates a new {@link Order} instance. if order is {@literal null} then order defaults to * {@link Sort#DEFAULT_DIRECTION}. * - * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}. - * @param property must not be {@literal null}. + * @param direction can be {@literal null}, will default to {@link Sort#DEFAULT_DIRECTION}. + * @param property must not be {@literal null}. * @param nullHandlingHint can be {@literal null}, will default to {@link NullHandling#NATIVE}. */ private JpaOrder(@Nullable Direction direction, String property, NullHandling nullHandlingHint) { @@ -346,7 +362,7 @@ private JpaOrder(@Nullable Direction direction, String property, NullHandling nu } private JpaOrder(@Nullable Direction direction, String property, boolean ignoreCase, NullHandling nullHandling, - boolean unsafe) { + boolean unsafe) { super(direction, property, ignoreCase, nullHandling); this.unsafe = unsafe; @@ -368,6 +384,8 @@ public JpaOrder with(NullHandling nullHandling) { * @param properties must not be {@literal null}. * @return */ + @Contract("_ -> new") + @CheckReturnValue public Sort withUnsafe(String... properties) { Assert.notEmpty(properties, "Properties must not be empty"); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/PredicateSpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/PredicateSpecification.java new file mode 100644 index 0000000000..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 9755052ed0..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 @@ -17,19 +17,31 @@ import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.CriteriaUpdate; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; -import java.io.Serial; import java.io.Serializable; import java.util.Arrays; import java.util.stream.StreamSupport; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; + +import org.springframework.lang.CheckReturnValue; +import org.springframework.lang.Contract; +import org.springframework.util.Assert; /** * Specification in the sense of Domain Driven Design. + *

+ * 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 @@ -38,94 +50,152 @@ * @author Jens Schauder * @author Daniel Shuy * @author Sergey Rukin + * @author Heeeun Cho + * @author Peter Aisher */ @FunctionalInterface public interface Specification extends Serializable { - @Serial 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) { - return spec == null // - ? (root, query, builder) -> null // - : (root, query, builder) -> { + Assert.notNull(spec, "Specification must not be null"); - Predicate predicate = spec.toPredicate(root, query, builder); - return predicate != null ? builder.not(predicate) : builder.disjunction(); - }; + 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 - * @deprecated since 3.5. */ - @Deprecated(since = "3.5.0", forRemoval = true) - 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 can be {@literal null} to allow overrides that accept {@link jakarta.persistence.criteria.CriteriaDelete} which is an {@link jakarta.persistence.criteria.AbstractQuery} but no {@link CriteriaQuery}. - * @param criteriaBuilder must not be {@literal null}. - * @return a {@link Predicate}, may be {@literal null}. + * @param other the other {@link PredicateSpecification}. + * @return the disjunction of the specifications + * @since 2.0 */ - @Nullable - Predicate toPredicate(Root root, @Nullable 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 */ @@ -135,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 */ @@ -156,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 ad78749e39..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 @@ -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, @Nullable 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/AuditingEntityListener.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingEntityListener.java index 9dc73af957..9c0fe493ca 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingEntityListener.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/AuditingEntityListener.java @@ -19,10 +19,11 @@ import jakarta.persistence.PreUpdate; import org.springframework.beans.factory.ObjectFactory; + +import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.annotation.Configurable; import org.springframework.data.auditing.AuditingHandler; import org.springframework.data.domain.Auditable; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/package-info.java index d14b03294c..b18b18cf18 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/package-info.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/domain/support/package-info.java @@ -1,5 +1,5 @@ /** * Implementation classes for auditing with JPA. */ -@org.springframework.lang.NonNullApi +@org.jspecify.annotations.NullMarked package org.springframework.data.jpa.domain.support; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContext.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContext.java index bc5a71c25c..2c0b813370 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContext.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaMetamodelMappingContext.java @@ -22,6 +22,8 @@ import java.util.function.Predicate; import org.springframework.data.jpa.provider.PersistenceProvider; + +import org.jspecify.annotations.Nullable; import org.springframework.data.jpa.util.JpaMetamodel; import org.springframework.data.mapping.PersistentPropertyPaths; import org.springframework.data.mapping.context.AbstractMappingContext; @@ -29,7 +31,6 @@ import org.springframework.data.mapping.model.Property; import org.springframework.data.mapping.model.SimpleTypeHolder; import org.springframework.data.util.TypeInformation; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -114,8 +115,7 @@ private Metamodels(Set metamodels) { * @param type must not be {@literal null}. * @return */ - @Nullable - public JpaMetamodel getMetamodel(TypeInformation type) { + public @Nullable JpaMetamodel getMetamodel(TypeInformation type) { Metamodel metamodel = getMetamodelFor(type.getType()); @@ -166,8 +166,7 @@ public boolean isMetamodelManagedType(Class type) { * @param type must not be {@literal null}. * @return can be {@literal null}. */ - @Nullable - private Metamodel getMetamodelFor(Class type) { + private @Nullable Metamodel getMetamodelFor(Class type) { for (Metamodel model : metamodels) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntityImpl.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntityImpl.java index 761a1600d0..611c82dff5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntityImpl.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentEntityImpl.java @@ -17,6 +17,7 @@ import java.util.Comparator; +import org.jspecify.annotations.Nullable; import org.springframework.data.annotation.Version; import org.springframework.data.jpa.provider.ProxyIdAccessor; import org.springframework.data.jpa.util.JpaMetamodel; @@ -63,7 +64,7 @@ public JpaPersistentEntityImpl(TypeInformation information, ProxyIdAccessor p } @Override - protected JpaPersistentProperty returnPropertyIfBetterIdPropertyCandidateOrNull(JpaPersistentProperty property) { + protected @Nullable JpaPersistentProperty returnPropertyIfBetterIdPropertyCandidateOrNull(JpaPersistentProperty property) { return property.isIdProperty() ? property : null; } @@ -117,7 +118,7 @@ private static class JpaProxyAwareIdentifierAccessor extends IdPropertyIdentifie } @Override - public Object getIdentifier() { + public @Nullable Object getIdentifier() { return proxyIdAccessor.shouldUseAccessorFor(bean) // ? proxyIdAccessor.getIdentifierFrom(bean)// diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImpl.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImpl.java index da773247e1..38678add2b 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImpl.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/mapping/JpaPersistentPropertyImpl.java @@ -25,6 +25,8 @@ import java.util.Set; import org.springframework.core.annotation.AnnotationUtils; + +import org.jspecify.annotations.Nullable; import org.springframework.data.annotation.AccessType.Type; import org.springframework.data.jpa.util.JpaMetamodel; import org.springframework.data.mapping.Association; @@ -34,7 +36,6 @@ import org.springframework.data.mapping.model.SimpleTypeHolder; import org.springframework.data.util.Lazy; import org.springframework.data.util.TypeInformation; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -57,13 +58,9 @@ class JpaPersistentPropertyImpl extends AnnotationBasedPersistentProperty> 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/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/projection/package-info.java new file mode 100644 index 0000000000..037c3c5eb3 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/projection/package-info.java @@ -0,0 +1,5 @@ +/** + * JPA specific support projection support. + */ +@org.jspecify.annotations.NullMarked +package org.springframework.data.jpa.projection; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/HibernateUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/HibernateUtils.java index 862cb5a1fb..f185237d4b 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/HibernateUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/HibernateUtils.java @@ -15,9 +15,12 @@ */ package org.springframework.data.jpa.provider; +import org.hibernate.query.NativeQuery; import org.hibernate.query.Query; import org.hibernate.query.spi.SqmQuery; -import org.springframework.lang.Nullable; +import org.hibernate.query.sql.spi.NamedNativeQueryMemento; +import org.hibernate.query.sqm.spi.NamedSqmQueryMemento; +import org.jspecify.annotations.Nullable; /** * Utility functions to work with Hibernate. Mostly using reflection to make sure common functionality can be executed @@ -41,11 +44,9 @@ private HibernateUtils() {} * @param query * @return */ - @Nullable - public static String getHibernateQuery(Object query) { + public @Nullable static String getHibernateQuery(Object query) { try { - // Try the new Hibernate implementation first if (query instanceof SqmQuery sqmQuery) { @@ -58,6 +59,22 @@ public static String getHibernateQuery(Object query) { return sqmQuery.getSqmStatement().toHqlString(); } + // Try the new Hibernate implementation first + if (query instanceof NamedSqmQueryMemento sqmQuery) { + + String hql = sqmQuery.getHqlString(); + + if (!hql.equals("")) { + return hql; + } + + return sqmQuery.getSqmStatement().toHqlString(); + } + + if (query instanceof NamedNativeQueryMemento nativeQuery) { + return nativeQuery.getSqlString(); + } + // Couple of cases in which this still breaks, see HHH-15389 } catch (RuntimeException o_O) {} @@ -68,4 +85,28 @@ public static String getHibernateQuery(Object query) { throw new IllegalArgumentException("Don't know how to extract the query string from " + query); } } + + public static boolean isNativeQuery(Object query) { + + // Try the new Hibernate implementation first + if (query instanceof SqmQuery) { + return false; + } + + if (query instanceof NativeQuery) { + return true; + } + + // Try the new Hibernate implementation first + if (query instanceof NamedSqmQueryMemento) { + + return false; + } + + if (query instanceof NamedNativeQueryMemento) { + return true; + } + + return false; + } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/JpaClassUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/JpaClassUtils.java index f00f4b849d..71971df3bf 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/JpaClassUtils.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/JpaClassUtils.java @@ -18,7 +18,8 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.metamodel.Metamodel; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; + import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -58,7 +59,7 @@ public static boolean isMetamodelOfType(Metamodel metamodel, String type) { return isOfType(metamodel, type, metamodel.getClass().getClassLoader()); } - private static boolean isOfType(Object source, String typeName, @Nullable ClassLoader classLoader) { + public static boolean isOfType(Object source, String typeName, @Nullable ClassLoader classLoader) { Assert.notNull(source, "Source instance must not be null"); Assert.hasText(typeName, "Target type name must not be null or empty"); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/PersistenceProvider.java index 2b5e0abbeb..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 @@ -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 { @@ -199,7 +255,7 @@ public String getCommentHintKey() { private static final Collection ALL = List.of(HIBERNATE, ECLIPSELINK, GENERIC_JPA); private static final ConcurrentReferenceHashMap, PersistenceProvider> CACHE = new ConcurrentReferenceHashMap<>(); - private final Iterable entityManagerClassNames; + 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 d999d7490b..e550368876 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/ProxyIdAccessor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/ProxyIdAccessor.java @@ -15,7 +15,7 @@ */ package org.springframework.data.jpa.provider; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * Interface for a persistence provider specific accessor of identifiers held in proxies. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java index aa39144da4..aa6c64abaf 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryComment.java @@ -17,7 +17,7 @@ import jakarta.persistence.Query; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * Interface to hide different implementations of query hints that insert comments into a {@link Query}. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryExtractor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryExtractor.java index 6bd6f4bace..6d25429525 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryExtractor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/QueryExtractor.java @@ -16,8 +16,9 @@ package org.springframework.data.jpa.provider; import jakarta.persistence.Query; +import jakarta.persistence.TypedQueryReference; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * Interface to hide different implementations to extract the original JPA query string from a {@link Query}. @@ -28,14 +29,25 @@ public interface QueryExtractor { /** - * Reverse engineers the query string from the {@link Query} object. This requires provider specific API as JPA does - * not provide access to the underlying query string as soon as one has created a {@link Query} instance of it. + * Reverse engineers the query string from the {@link Query} or a {@link TypedQueryReference} object. This requires + * provider specific API as JPA does not provide access to the underlying query string as soon as one has created a + * {@link Query} instance of it. * * @param query * @return the query string representing the query or {@literal null} if resolving is not possible. */ @Nullable - String extractQueryString(Query query); + String extractQueryString(Object query); + + /** + * Reverse engineers the query native flag from a {@link Query} or native query as JPA does not provide access to the + * underlying query string once a (named) query is constructed. + * + * @param query + * @return {@literal true} if the query is a native one. + * @since 4.0 + */ + boolean isNativeQuery(Object query); /** * Returns whether the extractor is able to extract the original query string from a given {@link Query}. @@ -43,4 +55,5 @@ public interface QueryExtractor { * @return */ boolean canExtractQuery(); + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/package-info.java index 02605bbf3d..87977ed2ce 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/package-info.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/provider/package-info.java @@ -1,5 +1,5 @@ /** * JPA provider-specific utilities. */ -@org.springframework.lang.NonNullApi +@org.jspecify.annotations.NullMarked package org.springframework.data.jpa.provider; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/JpaSpecificationExecutor.java index c3249502e4..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 @@ -15,23 +15,23 @@ */ package org.springframework.data.jpa.repository; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.CriteriaQuery; -import jakarta.persistence.criteria.Root; - import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.function.Function; +import org.jspecify.annotations.Nullable; + import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.DeleteSpecification; +import org.springframework.data.jpa.domain.PredicateSpecification; import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.domain.UpdateSpecification; import org.springframework.data.repository.query.FluentQuery; -import org.springframework.lang.Nullable; /** * Interface to allow execution of {@link Specification}s based on the JPA criteria API. @@ -41,44 +41,70 @@ * @author Diego Krupitza * @author Mark Paluch * @author Joshua Chen + * @see Specification + * @see org.springframework.data.jpa.domain.UpdateSpecification + * @see DeleteSpecification + * @see PredicateSpecification */ public interface JpaSpecificationExecutor { + /** + * 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}. - *

- * If no {@link Specification} is given all entities matching {@code } will be selected. * - * @param spec can be {@literal null}. + * @param spec must not be {@literal null}. * @return never {@literal null}. + * @see Specification#unrestricted() */ - List findAll(@Nullable Specification spec); + List findAll(Specification spec); /** * Returns a {@link Page} of entities matching the given {@link Specification}. - *

- * If no {@link Specification} is given all entities matching {@code } will be selected. * - * @param spec can be {@literal null}. + * @param spec must not be {@literal null}. * @param pageable must not be {@literal null}. * @return never {@literal null}. + * @see Specification#unrestricted() */ - Page findAll(@Nullable Specification spec, Pageable pageable); + 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. @@ -92,52 +118,113 @@ public interface JpaSpecificationExecutor { /** * Returns all entities matching the given {@link Specification} and {@link Sort}. - *

- * If no {@link Specification} is given all entities matching {@code } will be selected. * - * @param spec can be {@literal null}. + * @param spec must not be {@literal null}. * @param sort must not be {@literal null}. * @return never {@literal null}. + * @see Specification#unrestricted() */ - List findAll(@Nullable Specification spec, Sort sort); + 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. - *

- * If no {@link Specification} is given all entities matching {@code } will be counted. * * @param spec the {@link Specification} to count instances for, must not be {@literal null}. * @return the number of instances. + * @see Specification#unrestricted() */ - long count(@Nullable Specification spec); + 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}. - *

- * If no {@link Specification} is given all entities matching {@code } will be deleted. + * This method uses {@link jakarta.persistence.criteria.CriteriaDelete Criteria API bulk delete} that maps directly to + * database delete operations. The persistence context is not synchronized with the result of the bulk delete. * - * @param spec the {@link Specification} to use for the existence check, can not be {@literal null}. + * @param spec the {@link UpdateSpecification} to use for the delete query must not be {@literal null}. * @return the number of entities deleted. * @since 3.0 + * @see DeleteSpecification#unrestricted() */ - long delete(@Nullable Specification 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 @@ -153,7 +240,8 @@ public interface JpaSpecificationExecutor { * @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 @@ -181,6 +269,21 @@ default SpecificationFluentQuery project(String... 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}. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java index d10c90b68c..d12036c74b 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/NativeQuery.java @@ -94,4 +94,5 @@ * Name of the {@link jakarta.persistence.SqlResultSetMapping @SqlResultSetMapping(name)} to apply for this query. */ String sqlResultSetMapping() default ""; + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java index 12ff41bb71..4405d29bbb 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Query.java @@ -90,4 +90,5 @@ * @since 3.0 */ Class queryRewriter() default QueryRewriter.IdentityQueryRewriter.class; + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Temporal.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Temporal.java index e7492ab305..1a5d941662 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Temporal.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/Temporal.java @@ -30,10 +30,12 @@ * * @author Thomas Darimont * @author Oliver Gierke + * @deprecated since 4.0. Please use {@literal java.time} types instead. */ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) @Documented +@Deprecated(since = "4.0", forRemoval = true) public @interface Temporal { /** diff --git a/spring-data-jpa/src/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..a7d8a1377c --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotMetamodel.java @@ -0,0 +1,168 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.aot; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.metamodel.EmbeddableType; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.ManagedType; +import jakarta.persistence.metamodel.Metamodel; +import jakarta.persistence.spi.ClassTransformer; +import jakarta.persistence.spi.PersistenceUnitInfo; + +import java.net.URL; +import java.util.Collection; +import java.util.List; +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.instrument.classloading.SimpleThrowawayClassLoader; +import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo; +import org.springframework.orm.jpa.persistenceunit.PersistenceManagedTypes; + +/** + * 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) { + + MutablePersistenceUnitInfo persistenceUnitInfo = new MutablePersistenceUnitInfo() { + @Override + public ClassLoader getNewTempClassLoader() { + return new SimpleThrowawayClassLoader(this.getClass().getClassLoader()); + } + + @Override + public void addTransformer(ClassTransformer classTransformer) { + // just ignore it + } + }; + persistenceUnitInfo.setPersistenceUnitName("AotMetaModel"); + + this.entityManagerFactory = init(() -> { + + managedTypes.forEach(persistenceUnitInfo::addManagedClassName); + + persistenceUnitInfo.setPersistenceProviderClassName(HibernatePersistenceProvider.class.getName()); + + return new PersistenceUnitInfoDescriptor(persistenceUnitInfo) { + + @Override + public List getManagedClassNames() { + return persistenceUnitInfo.getManagedClassNames(); + } + + @Override + public URL getPersistenceUnitRootUrl() { + return persistenceUnitRootUrl != null ? persistenceUnitRootUrl : super.getPersistenceUnitRootUrl(); + } + + }; + }); + } + + 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..9ef9705bf2 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/AotQueries.java @@ -0,0 +1,121 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.aot; + + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +import org.springframework.data.jpa.repository.query.DeclaredQuery; +import org.springframework.data.jpa.repository.query.QueryEnhancer; +import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; +import org.springframework.data.repository.aot.generate.QueryMetadata; +import org.springframework.util.StringUtils; + +/** + * Value object capturing queries used for repository query methods. + * + * @author Mark Paluch + * @since 4.0 + */ +record AotQueries(AotQuery result, AotQuery count) { + + /** + * Derive a count query from the given query. + */ + public static AotQueries withDerivedCountQuery(T query, Function queryMapper, + @Nullable String countProjection, QueryEnhancerSelector selector) { + + DeclaredQuery underlyingQuery = queryMapper.apply(query); + QueryEnhancer queryEnhancer = selector.select(underlyingQuery).create(underlyingQuery); + + String derivedCountQuery = queryEnhancer + .createCountQueryFor(StringUtils.hasText(countProjection) ? countProjection : null); + + 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); + } + + /** + * 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..564fb50ef6 --- /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 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 80b67fd896..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 @@ -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,9 +93,8 @@ 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)); 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..9a764c255c --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/aot/QueriesFactory.java @@ -0,0 +1,341 @@ +/* + * 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)); + } + + 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/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/package-info.java index c5fb3792d5..a186187b38 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/package-info.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/cdi/package-info.java @@ -1,5 +1,5 @@ /** * CDI support for Spring Data JPA Repositories. */ -@org.springframework.lang.NonNullApi +@org.jspecify.annotations.NullMarked package org.springframework.data.jpa.repository.cdi; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParser.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParser.java index 8625119632..53ec098e86 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParser.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/AuditingBeanDefinitionParser.java @@ -17,6 +17,7 @@ import static org.springframework.beans.factory.support.BeanDefinitionBuilder.*; +import org.jspecify.annotations.Nullable; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.parsing.BeanComponentDefinition; import org.springframework.beans.factory.support.AbstractBeanDefinition; @@ -45,6 +46,7 @@ public class AuditingBeanDefinitionParser implements BeanDefinitionParser { private final SpringConfiguredBeanDefinitionParser springConfiguredParser = new SpringConfiguredBeanDefinitionParser(); @Override + @SuppressWarnings("NullAway") public BeanDefinition parse(Element element, ParserContext parser) { springConfiguredParser.parse(element, parser); @@ -90,7 +92,7 @@ private static class SpringConfiguredBeanDefinitionParser implements BeanDefinit private static final String BEAN_CONFIGURER_ASPECT_CLASS_NAME = "org.springframework.beans.factory.aspectj.AnnotationBeanConfigurerAspect"; @Override - public BeanDefinition parse(Element element, ParserContext parserContext) { + public @Nullable BeanDefinition parse(Element element, ParserContext parserContext) { if (!parserContext.getRegistry().containsBeanDefinition(BEAN_CONFIGURER_ASPECT_BEAN_NAME)) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaRepositories.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/EnableJpaRepositories.java index 3ff333ea7c..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 @@ -28,6 +28,7 @@ import org.springframework.context.annotation.ComponentScan.Filter; import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Lazy; +import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; import org.springframework.data.repository.config.BootstrapMode; import org.springframework.data.repository.config.DefaultRepositoryBaseClass; @@ -56,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 {}; @@ -83,46 +96,39 @@ * Returns the postfix to be used when looking up custom repository implementations. Defaults to {@literal Impl}. So * for a repository named {@code PersonRepository} the corresponding implementation class will be looked up scanning * for {@code PersonRepositoryImpl}. - * - * @return */ String repositoryImplementationPostfix() default "Impl"; /** * Configures the location of where to find the Spring Data named queries properties file. Will default to * {@code META-INF/jpa-named-queries.properties}. - * - * @return */ String namedQueriesLocation() default ""; /** * Returns the key of the {@link QueryLookupStrategy} to be used for lookup queries for query methods. Defaults to * {@link Key#CREATE_IF_NOT_FOUND}. - * - * @return */ Key queryLookupStrategy() default Key.CREATE_IF_NOT_FOUND; /** * Returns the {@link FactoryBean} class to be used for each repository instance. Defaults to * {@link JpaRepositoryFactoryBean}. - * - * @return */ Class repositoryFactoryBeanClass() default JpaRepositoryFactoryBean.class; /** * Configure the repository base class to be used to create repository proxies for this particular configuration. * - * @return * @since 1.9 */ Class repositoryBaseClass() default DefaultRepositoryBaseClass.class; /** * Configure a specific {@link BeanNameGenerator} to be used when creating the repository beans. - * @return the {@link BeanNameGenerator} to be used or the base {@link BeanNameGenerator} interface to indicate context default. + * + * @return the {@link BeanNameGenerator} to be used or the base {@link BeanNameGenerator} interface to indicate + * context default. * @since 3.4 */ Class nameGenerator() default BeanNameGenerator.class; @@ -132,22 +138,18 @@ /** * Configures the name of the {@link EntityManagerFactory} bean definition to be used to create repositories * discovered through this annotation. Defaults to {@code entityManagerFactory}. - * - * @return */ String entityManagerFactoryRef() default "entityManagerFactory"; /** * Configures the name of the {@link PlatformTransactionManager} bean definition to be used to create repositories * discovered through this annotation. Defaults to {@code transactionManager}. - * - * @return */ String transactionManagerRef() default "transactionManager"; /** * Configures whether nested repository-interfaces (e.g. defined as inner classes) should be discovered by the - * repositories infrastructure. + * repository infrastructure. */ boolean considerNestedRepositories() default false; @@ -169,7 +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; @@ -181,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/JpaMetamodelMappingContextFactoryBean.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaMetamodelMappingContextFactoryBean.java index 2bd8cd5ec8..9ccfa3f038 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaMetamodelMappingContextFactoryBean.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaMetamodelMappingContextFactoryBean.java @@ -23,6 +23,8 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + import org.springframework.beans.BeansException; import org.springframework.beans.factory.BeanFactoryUtils; import org.springframework.beans.factory.FactoryBean; @@ -32,7 +34,6 @@ import org.springframework.context.ApplicationContextAware; import org.springframework.data.jpa.mapping.JpaMetamodelMappingContext; import org.springframework.data.util.StreamUtils; -import org.springframework.lang.Nullable; /** * {@link FactoryBean} to setup {@link JpaMetamodelMappingContext} instances from Spring configuration. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/JpaRepositoryConfigExtension.java index 1bcf8073a8..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 @@ -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, @@ -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/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/package-info.java index 6e54455cfe..e2186fa63a 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/package-info.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/config/package-info.java @@ -1,5 +1,5 @@ /** * Classes for JPA namespace configuration. */ -@org.springframework.lang.NonNullApi +@org.jspecify.annotations.NullMarked package org.springframework.data.jpa.repository.config; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/package-info.java index 61ce846166..702e410e85 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/package-info.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/package-info.java @@ -1,5 +1,5 @@ /** * Interfaces and annotations for JPA specific repositories. */ -@org.springframework.lang.NonNullApi +@org.jspecify.annotations.NullMarked package org.springframework.data.jpa.repository; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractJpaQuery.java index fb9821c184..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 @@ -25,15 +25,12 @@ import java.lang.reflect.Constructor; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collection; -import java.util.HashMap; import java.util.List; -import java.util.Map; -import java.util.Set; import java.util.function.UnaryOperator; import java.util.stream.Collectors; +import org.jspecify.annotations.Nullable; + import org.springframework.beans.BeanUtils; import org.springframework.core.MethodParameter; import org.springframework.core.convert.converter.Converter; @@ -48,14 +45,14 @@ import org.springframework.data.jpa.repository.query.JpaQueryExecution.StreamExecution; import org.springframework.data.jpa.repository.support.QueryHints; import org.springframework.data.jpa.util.JpaMetamodel; +import org.springframework.data.jpa.util.TupleBackedMap; import org.springframework.data.mapping.PreferredConstructor; import org.springframework.data.mapping.model.PreferredConstructorDiscoverer; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.util.Lazy; -import org.springframework.jdbc.support.JdbcUtils; -import org.springframework.lang.Nullable; +import org.springframework.lang.Contract; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; @@ -104,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 { @@ -123,6 +120,15 @@ public JpaQueryMethod getQueryMethod() { return method; } + /** + * Returns {@literal true} if the query has a dedicated count query associated with it or {@literal false} if the + * count query shall be derived. + * + * @return {@literal true} if the query has a dedicated count query {@literal false} if the * count query is derived. + * @since 3.5 + */ + public abstract boolean hasDeclaredCountQuery(); + /** * Returns the {@link EntityManager}. * @@ -141,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); } /** @@ -152,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); @@ -172,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; } @@ -193,6 +205,8 @@ protected JpaQueryExecution getExecution() { * @param query * @return */ + @SuppressWarnings("NullAway") + @Contract("_, _ -> param1") protected T applyHints(T query, JpaQueryMethod method) { List hints = method.getHints(); @@ -242,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) { @@ -283,8 +297,7 @@ protected Query createCountQuery(JpaParametersParameterAccessor values) { * @return * @since 2.0.5 */ - @Nullable - protected Class getTypeToRead(ReturnedType returnedType) { + protected @Nullable Class getTypeToRead(ReturnedType returnedType) { if (PersistenceProvider.ECLIPSELINK.equals(provider)) { return null; @@ -343,7 +356,7 @@ public TupleConverter(ReturnedType type, boolean nativeQuery) { Assert.notNull(type, "Returned type must not be null"); this.type = type; - this.tupleWrapper = nativeQuery ? FallbackTupleWrapper::new : UnaryOperator.identity(); + this.tupleWrapper = nativeQuery ? TupleBackedMap::underscoreAware : UnaryOperator.identity(); this.dtoProjection = type.isProjecting() && !type.getReturnedType().isInterface() && !type.getInputProperties().isEmpty(); @@ -467,181 +480,6 @@ private static boolean areAssignmentCompatible(Class to, Class from) { return ClassUtils.isAssignable(to, from); } - /** - * A {@link Map} implementation which delegates all calls to a {@link Tuple}. Depending on the provided - * {@link Tuple} implementation it might return the same value for various keys of which only one will appear in the - * key/entry set. - * - * @author Jens Schauder - */ - private static class TupleBackedMap implements Map { - - private static final String UNMODIFIABLE_MESSAGE = "A TupleBackedMap cannot be modified"; - - private final Tuple tuple; - - TupleBackedMap(Tuple tuple) { - this.tuple = tuple; - } - - @Override - public int size() { - return tuple.getElements().size(); - } - - @Override - public boolean isEmpty() { - return tuple.getElements().isEmpty(); - } - - /** - * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code false}. - * Otherwise this returns {@code true} even when the value from the backing {@code Tuple} is {@code null}. - * - * @param key the key for which to get the value from the map. - * @return whether the key is an element of the backing tuple. - */ - @Override - public boolean containsKey(Object key) { - - try { - tuple.get((String) key); - return true; - } catch (IllegalArgumentException e) { - return false; - } - } - - @Override - public boolean containsValue(Object value) { - return Arrays.asList(tuple.toArray()).contains(value); - } - - /** - * If the key is not a {@code String} or not a key of the backing {@link Tuple} this returns {@code null}. - * Otherwise the value from the backing {@code Tuple} is returned, which also might be {@code null}. - * - * @param key the key for which to get the value from the map. - * @return the value of the backing {@link Tuple} for that key or {@code null}. - */ - @Override - @Nullable - public Object get(Object key) { - - if (!(key instanceof String)) { - return null; - } - - try { - return tuple.get((String) key); - } catch (IllegalArgumentException e) { - return null; - } - } - - @Override - public Object put(String key, Object value) { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); - } - - @Override - public Object remove(Object key) { - throw new UnsupportedOperationException(UNMODIFIABLE_MESSAGE); - } - - @Override - public void putAll(Map 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()); - } - } } - private 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/repository/query/AbstractStringBasedJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQuery.java index 851a3918e0..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 @@ -23,20 +23,19 @@ import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; +import org.jspecify.annotations.Nullable; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.expression.ValueEvaluationContextProvider; import org.springframework.data.jpa.repository.QueryRewriter; -import org.springframework.data.mapping.PropertyPath; -import org.springframework.data.mapping.PropertyReferenceException; +import org.springframework.data.repository.query.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.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. @@ -53,15 +52,15 @@ */ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery { - private final StringQuery query; + private final EntityQuery query; private final Map, Boolean> knownProjections = new ConcurrentHashMap<>(); - private final Lazy countQuery; + private final Lazy countQuery; private final ValueExpressionDelegate valueExpressionDelegate; - private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache(); 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 @@ -70,40 +69,49 @@ abstract class AbstractStringBasedJpaQuery extends AbstractJpaQuery { * @param method must not be {@literal null}. * @param em must not be {@literal null}. * @param queryString must not be {@literal null}. - * @param countQueryString must not be {@literal null}. - * @param queryRewriter must not be {@literal null}. - * @param valueExpressionDelegate must not be {@literal null}. + * @param queryConfiguration must not be {@literal null}. */ - public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, - @Nullable String countQueryString, QueryRewriter queryRewriter, ValueExpressionDelegate valueExpressionDelegate) { + AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, + @Nullable String countQueryString, JpaQueryConfiguration queryConfiguration) { + this(method, em, method.getDeclaredQuery(queryString), + countQueryString != null ? method.getDeclaredQuery(countQueryString) : null, queryConfiguration); + } + + /** + * Creates a new {@link AbstractStringBasedJpaQuery} from the given {@link JpaQueryMethod}, {@link EntityManager} and + * query {@link String}. + * + * @param method must not be {@literal null}. + * @param em must not be {@literal null}. + * @param query must not be {@literal null}. + * @param countQuery can be {@literal null}. + * @param queryConfiguration must not be {@literal null}. + */ + public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, DeclaredQuery query, + @Nullable DeclaredQuery countQuery, JpaQueryConfiguration queryConfiguration) { super(method, em); - Assert.hasText(queryString, "Query string must not be null or empty"); - Assert.notNull(valueExpressionDelegate, "ValueExpressionDelegate must not be null"); - Assert.notNull(queryRewriter, "QueryRewriter must not be null"); + Assert.notNull(query, "Query must not be null"); + Assert.notNull(queryConfiguration, "JpaQueryConfiguration must not be null"); - this.valueExpressionDelegate = valueExpressionDelegate; + this.valueExpressionDelegate = queryConfiguration.getValueExpressionDelegate(); this.valueExpressionContextProvider = valueExpressionDelegate.createValueContextProvider(method.getParameters()); - this.query = new ExpressionBasedStringQuery(queryString, method.getEntityInformation(), valueExpressionDelegate, - method.isNativeQuery()); - this.countQuery = Lazy.of(() -> { + this.query = TemplatedQuery.create(query, method.getEntityInformation(), queryConfiguration); + this.hasDeclaredCountQuery = countQuery != null; - if (StringUtils.hasText(countQueryString)) { + this.countQuery = Lazy.of(() -> { - return new ExpressionBasedStringQuery(countQueryString, method.getEntityInformation(), valueExpressionDelegate, - method.isNativeQuery()); + if (countQuery != null) { + return TemplatedQuery.create(countQuery, method.getEntityInformation(), queryConfiguration); } - return query.deriveCountQuery(method.getCountQueryProjection()); - }); - - this.countParameterBinder = Lazy.of(() -> { - return this.createBinder(this.countQuery.get()); + return this.query.deriveCountQuery(method.getCountQueryProjection()); }); - this.queryRewriter = queryRewriter; + this.countParameterBinder = Lazy.of(() -> this.createBinder(this.countQuery.get())); + this.queryRewriter = queryConfiguration.getQueryRewriter(method); JpaParameters parameters = method.getParameters(); @@ -117,8 +125,14 @@ public AbstractStringBasedJpaQuery(JpaQueryMethod method, EntityManager em, Stri } } - 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 @@ -127,14 +141,12 @@ public Query doCreateQuery(JpaParametersParameterAccessor accessor) { Sort sort = accessor.getSort(); ResultProcessor processor = getQueryMethod().getResultProcessor().withDynamicProjection(accessor); ReturnedType returnedType = getReturnedType(processor); - String sortedQueryString = getSortedQueryString(sort, returnedType); - Query query = createJpaQuery(sortedQueryString, sort, accessor.getPageable(), returnedType); - - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(sortedQueryString, query); + QueryProvider sortedQuery = getSortedQuery(sort, returnedType); + Query query = createJpaQuery(sortedQuery, sort, accessor.getPageable(), returnedType); // it is ok to reuse the binding contained in the ParameterBinder, although we create a new query String because the // parameters in the query do not change. - return parameterBinder.get().bindAndPrepare(query, metadata, accessor); + return parameterBinder.get().bindAndPrepare(query, accessor); } /** @@ -144,13 +156,12 @@ public Query doCreateQuery(JpaParametersParameterAccessor accessor) { * @param processor * @return */ - private ReturnedType getReturnedType(ResultProcessor processor) { + ReturnedType getReturnedType(ResultProcessor processor) { ReturnedType returnedType = processor.getReturnedType(); - Class returnedJavaType = processor.getReturnedType().getReturnedType(); + Class returnedJavaType = returnedType.getReturnedType(); - if (query.isDefaultProjection() || !returnedType.isProjecting() || returnedJavaType.isInterface() - || query.isNativeQuery()) { + if (!returnedType.isProjecting() || returnedJavaType.isInterface() || query.isNative()) { return returnedType; } @@ -160,59 +171,19 @@ private ReturnedType getReturnedType(ResultProcessor processor) { return returnedType; } - if ((known != null && !known) || returnedJavaType.isArray()) { + if ((known != null && !known) || returnedJavaType.isArray() || getMetamodel().isJpaManaged(returnedJavaType) + || !returnedType.needsCustomConstruction()) { if (known == null) { knownProjections.put(returnedJavaType, false); } return new NonProjectingReturnedType(returnedType); } - String alias = query.getAlias(); - String projection = query.getProjection(); - - // we can handle single-column and no function projections here only - if (StringUtils.hasText(projection) && (projection.indexOf(',') != -1 || projection.indexOf('(') != -1)) { - return returnedType; - } - - if (StringUtils.hasText(alias) && StringUtils.hasText(projection)) { - alias = alias.trim(); - projection = projection.trim(); - if (projection.startsWith(alias + ".")) { - projection = projection.substring(alias.length() + 1); - } - } - - if (StringUtils.hasText(projection)) { - - int space = projection.indexOf(' '); - - if (space != -1) { - projection = projection.substring(0, space); - } - - Class propertyType; - - try { - PropertyPath from = PropertyPath.from(projection, getQueryMethod().getEntityInformation().getJavaType()); - propertyType = from.getLeafType(); - } catch (PropertyReferenceException ignored) { - propertyType = null; - } - - if (propertyType == null - || (returnedJavaType.isAssignableFrom(propertyType) || propertyType.isAssignableFrom(returnedJavaType))) { - knownProjections.put(returnedJavaType, false); - return new NonProjectingReturnedType(returnedType); - } else { - knownProjections.put(returnedJavaType, true); - } - } - + knownProjections.put(returnedJavaType, true); return returnedType; } - String getSortedQueryString(Sort sort, ReturnedType returnedType) { + QueryProvider getSortedQuery(Sort sort, ReturnedType returnedType) { return querySortRewriter.getSorted(query, sort, returnedType); } @@ -221,7 +192,7 @@ protected ParameterBinder createBinder() { return createBinder(query); } - protected ParameterBinder createBinder(DeclaredQuery query) { + protected ParameterBinder createBinder(ParametrizedQuery query) { return ParameterBinderFactory.createQueryAwareBinder(getQueryMethod().getParameters(), query, valueExpressionDelegate, valueExpressionContextProvider); } @@ -238,9 +209,8 @@ protected Query doCreateCountQuery(JpaParametersParameterAccessor accessor) { ? em.createNativeQuery(queryStringToUse) // : em.createQuery(queryStringToUse, Long.class); - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(queryString, query); - - countParameterBinder.get().bind(metadata.withQuery(query), accessor, QueryParameterSetter.ErrorHandling.LENIENT); + countParameterBinder.get().bind(new QueryParameterSetter.BindableQuery(query), accessor, + QueryParameterSetter.ErrorHandling.LENIENT); return query; } @@ -248,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(); } @@ -263,11 +233,11 @@ public DeclaredQuery getCountQuery() { * Creates an appropriate JPA query from an {@link EntityManager} according to the current {@link AbstractJpaQuery} * type. */ - protected Query createJpaQuery(String queryString, Sort sort, @Nullable Pageable pageable, + protected Query createJpaQuery(QueryProvider query, Sort sort, @Nullable Pageable pageable, ReturnedType returnedType) { EntityManager em = getEntityManager(); - String queryToUse = potentiallyRewriteQuery(queryString, sort, pageable); + String queryToUse = potentiallyRewriteQuery(query.getQueryString(), sort, pageable); if (this.query.hasConstructorExpression() || this.query.isDefaultProjection()) { return em.createQuery(queryToUse); @@ -296,9 +266,8 @@ protected String potentiallyRewriteQuery(String originalQuery, Sort sort, @Nulla : queryRewriter.rewrite(originalQuery, sort); } - String applySorting(CachableQuery cachableQuery) { - - return QueryEnhancerFactory.forQuery(cachableQuery.getDeclaredQuery()) + QueryProvider applySorting(CachableQuery cachableQuery) { + return cachableQuery.getDeclaredQuery() .rewrite(new DefaultQueryRewriteInformation(cachableQuery.getSort(), cachableQuery.getReturnedType())); } @@ -306,7 +275,7 @@ String applySorting(CachableQuery cachableQuery) { * Query Sort Rewriter interface. */ interface QuerySortRewriter { - String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType); + QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType); } /** @@ -316,29 +285,28 @@ enum SimpleQuerySortRewriter implements QuerySortRewriter { INSTANCE; - public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType) { - - return QueryEnhancerFactory.forQuery(query).rewrite(new DefaultQueryRewriteInformation(sort, returnedType)); + public QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType) { + return query.rewrite(new DefaultQueryRewriteInformation(sort, returnedType)); } } static class UnsortedCachingQuerySortRewriter implements QuerySortRewriter { - private volatile String cachedQueryString; + private volatile @Nullable QueryProvider cachedQuery; - public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType) { + public QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType) { if (sort.isSorted()) { throw new UnsupportedOperationException("NoOpQueryCache does not support sorting"); } - String cachedQueryString = this.cachedQueryString; - if (cachedQueryString == null) { - this.cachedQueryString = cachedQueryString = QueryEnhancerFactory.forQuery(query) + QueryProvider cachedQuery = this.cachedQuery; + if (cachedQuery == null) { + this.cachedQuery = cachedQuery = query .rewrite(new DefaultQueryRewriteInformation(sort, returnedType)); } - return cachedQueryString; + return cachedQuery; } } @@ -347,22 +315,22 @@ public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedTyp */ class CachingQuerySortRewriter implements QuerySortRewriter { - private final ConcurrentLruCache queryCache = new ConcurrentLruCache<>(16, + private final ConcurrentLruCache queryCache = new ConcurrentLruCache<>(16, AbstractStringBasedJpaQuery.this::applySorting); - private volatile String cachedQueryString; + private volatile @Nullable QueryProvider cachedQuery; @Override - public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedType) { + public QueryProvider getSorted(EntityQuery query, Sort sort, ReturnedType returnedType) { if (sort.isUnsorted()) { - String cachedQueryString = this.cachedQueryString; - if (cachedQueryString == null) { - this.cachedQueryString = cachedQueryString = queryCache.get(new CachableQuery(query, sort, returnedType)); + QueryProvider cachedQuery = this.cachedQuery; + if (cachedQuery == null) { + this.cachedQuery = cachedQuery = queryCache.get(new CachableQuery(query, sort, returnedType)); } - return cachedQueryString; + return cachedQuery; } return queryCache.get(new CachableQuery(query, sort, returnedType)); @@ -378,21 +346,21 @@ public String getSorted(DeclaredQuery query, Sort sort, ReturnedType returnedTyp */ static class CachableQuery { - private final DeclaredQuery declaredQuery; + private final EntityQuery query; private final String queryString; private final Sort sort; private final ReturnedType returnedType; - CachableQuery(DeclaredQuery query, Sort sort, ReturnedType returnedType) { + CachableQuery(EntityQuery query, Sort sort, ReturnedType returnedType) { - this.declaredQuery = query; + this.query = query; this.queryString = query.getQueryString(); this.sort = sort; this.returnedType = returnedType; } - DeclaredQuery getDeclaredQuery() { - return declaredQuery; + EntityQuery getDeclaredQuery() { + return query; } Sort getSort() { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarException.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarException.java index ab3b51b7b3..e731d9f3bc 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarException.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/BadJpqlGrammarException.java @@ -15,8 +15,9 @@ */ package org.springframework.data.jpa.repository.query; +import org.jspecify.annotations.Nullable; + import org.springframework.dao.InvalidDataAccessResourceUsageException; -import org.springframework.lang.Nullable; /** * An exception thrown if the JPQL query is invalid. @@ -29,12 +30,12 @@ public class BadJpqlGrammarException extends InvalidDataAccessResourceUsageExcep private final String jpql; - public BadJpqlGrammarException(String message, String jpql, @Nullable Throwable cause) { + public BadJpqlGrammarException(@Nullable String message, String jpql, @Nullable Throwable cause) { this(message, jpql, "JPQL", cause); } - BadJpqlGrammarException(String message, String grammar, String jpql, @Nullable Throwable cause) { - super(message + "; Bad " + grammar + " grammar [" + jpql + "]", cause); + BadJpqlGrammarException(@Nullable String message, String grammar, String jpql, @Nullable Throwable cause) { + super("%sBad %s grammar [%s]".formatted(message != null ? message + "; " : "", grammar, jpql), cause); this.jpql = jpql; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQueries.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQueries.java new file mode 100644 index 0000000000..2f6db9c5f7 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQueries.java @@ -0,0 +1,148 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import org.springframework.util.ObjectUtils; + +/** + * Utility class encapsulating {@code DeclaredQuery} implementations. + * + * @author Christoph Strobl + * @author Mark Paluch + * @since 4.0 + */ +class DeclaredQueries { + + static final class JpqlQuery implements DeclaredQuery { + + private final String jpql; + + JpqlQuery(String jpql) { + this.jpql = jpql; + } + + @Override + public boolean isNative() { + return false; + } + + @Override + public String getQueryString() { + return jpql; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof JpqlQuery jpqlQuery)) { + return false; + } + return ObjectUtils.nullSafeEquals(jpql, jpqlQuery.jpql); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHashCode(jpql); + } + + @Override + public String toString() { + return "JPQL[" + jpql + "]"; + } + + } + + static final class NativeQuery implements DeclaredQuery { + + private final String sql; + + NativeQuery(String sql) { + this.sql = sql; + } + + @Override + public boolean isNative() { + return true; + } + + @Override + public String getQueryString() { + return sql; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof NativeQuery that)) { + return false; + } + return ObjectUtils.nullSafeEquals(sql, that.sql); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHashCode(sql); + } + + @Override + public String toString() { + return "Native[" + sql + "]"; + } + + } + + /** + * A rewritten {@link DeclaredQuery} holding a reference to its original query. + */ + static class RewrittenQuery implements DeclaredQuery { + + private final DeclaredQuery source; + private final String queryString; + + public RewrittenQuery(DeclaredQuery source, String queryString) { + this.source = source; + this.queryString = queryString; + } + + @Override + public boolean isNative() { + return source.isNative(); + } + + @Override + public String getQueryString() { + return queryString; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof RewrittenQuery that)) { + return false; + } + return ObjectUtils.nullSafeEquals(queryString, that.queryString); + } + + @Override + public int hashCode() { + return ObjectUtils.nullSafeHashCode(queryString); + } + + @Override + public String toString() { + return isNative() ? "Rewritten Native[" + queryString + "]" : "Rewritten JPQL[" + queryString + "]"; + } + + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java index 70bc5c829b..2cea734dbc 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DeclaredQuery.java @@ -15,99 +15,71 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.List; - -import org.springframework.lang.Nullable; -import org.springframework.util.ObjectUtils; - /** - * A wrapper for a String representation of a query offering information about the query. + * Interface defining the contract to represent a declared query. + *

+ * 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..02c820a111 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultEntityQuery.java @@ -0,0 +1,170 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import java.util.List; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +/** + * Encapsulation of a JPA query string, typically returning entities or DTOs. Provides access to parameter bindings. + *

+ * The internal {@link PreprocessedQuery query string} is cleaned from decorated parameters like {@literal %:lastname%} + * and the matching bindings take care of applying the decorations in the {@link ParameterBinding#prepare(Object)} + * method. Note that this class also handles replacing SpEL expressions with synthetic bind parameters. + * + * @author Oliver Gierke + * @author Thomas Darimont + * @author Oliver Wehrens + * @author Mark Paluch + * @author Jens Schauder + * @author Diego Krupitza + * @author Greg Turnquist + * @author Yuriy Tsarkov + * @since 4.0 + */ +class DefaultEntityQuery implements EntityQuery, DeclaredQuery { + + private final PreprocessedQuery query; + private final QueryEnhancer queryEnhancer; + + DefaultEntityQuery(PreprocessedQuery query, QueryEnhancerFactory queryEnhancerFactory) { + this.query = query; + this.queryEnhancer = queryEnhancerFactory.create(query); + } + + @Override + public T doWithEnhancer(Function function) { + return function.apply(queryEnhancer); + } + + @Override + public boolean isNative() { + return query.isNative(); + } + + @Override + public String getQueryString() { + return query.getQueryString(); + } + + @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 daa30cbb87..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 @@ -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/DefaultQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java index 8dba004f4b..456c3139b3 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancer.java @@ -15,10 +15,7 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.Set; - -import org.springframework.data.domain.Sort; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * The implementation of the Regex-based {@link QueryEnhancer} using {@link QueryUtils}. @@ -26,30 +23,18 @@ * @author Diego Krupitza * @since 2.7.0 */ -public class DefaultQueryEnhancer implements QueryEnhancer { +class DefaultQueryEnhancer implements QueryEnhancer { - private final DeclaredQuery query; + private final QueryProvider query; private final boolean hasConstructorExpression; - private final String alias; + private final @Nullable String alias; private final String projection; - private final Set joinAliases; - public DefaultQueryEnhancer(DeclaredQuery query) { + public DefaultQueryEnhancer(QueryProvider query) { this.query = query; this.hasConstructorExpression = QueryUtils.hasConstructorExpression(query.getQueryString()); this.alias = QueryUtils.detectAlias(query.getQueryString()); this.projection = QueryUtils.getProjection(this.query.getQueryString()); - this.joinAliases = QueryUtils.getOuterJoinAliases(this.query.getQueryString()); - } - - @Override - public String applySorting(Sort sort) { - return QueryUtils.applySorting(this.query.getQueryString(), sort, this.alias); - } - - @Override - public String applySorting(Sort sort, @Nullable String alias) { - return QueryUtils.applySorting(this.query.getQueryString(), sort, alias); } @Override @@ -59,7 +44,9 @@ public String rewrite(QueryRewriteInformation rewriteInformation) { @Override public String createCountQueryFor(@Nullable String countProjection) { - return QueryUtils.createCountQueryFor(this.query.getQueryString(), countProjection, this.query.isNativeQuery()); + + boolean nativeQuery = this.query instanceof DeclaredQuery dc ? dc.isNative() : true; + return QueryUtils.createCountQueryFor(this.query.getQueryString(), countProjection, nativeQuery); } @Override @@ -68,7 +55,7 @@ public boolean hasConstructorExpression() { } @Override - public String detectAlias() { + public @Nullable String detectAlias() { return this.alias; } @@ -78,12 +65,8 @@ public String getProjection() { } @Override - public Set getJoinAliases() { - return this.joinAliases; - } - - @Override - public DeclaredQuery getQuery() { + public QueryProvider getQuery() { return this.query; } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java index 4593697a4d..28de9ba657 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/DtoProjectionTransformerDelegate.java @@ -17,6 +17,11 @@ 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; /** @@ -25,7 +30,8 @@ * 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})
  • + *
  • 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 @@ -34,42 +40,94 @@ 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 transformSelectionList(QueryTokenStream selectionList) { + 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) { - if (!returnedType.isProjecting() || returnedType.getReturnedType().isInterface() - || selectionList.stream().anyMatch(it -> it.equals(TOKEN_NEW))) { - return selectionList; + 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(); } - QueryRenderer.QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.TOKEN_NEW); - builder.append(QueryTokens.token(returnedType.getReturnedType().getName())); - builder.append(QueryTokens.TOKEN_OPEN_PAREN); + return QueryTokenStream.empty(); + } + + private static class DetachedStream extends QueryRenderer { - // assume the selection points to the document - if (selectionList.size() == 1) { + private final QueryTokenStream delegate; - builder.appendInline(QueryTokenStream.concat(returnedType.getInputProperties(), property -> { + private DetachedStream(QueryTokenStream delegate) { + this.delegate = delegate; + } - QueryRenderer.QueryRendererBuilder prop = QueryRenderer.builder(); - prop.append(QueryTokens.token(selectionList.getFirst().value())); - prop.append(QueryTokens.TOKEN_DOT); - prop.append(QueryTokens.token(property)); + @Override + public boolean isExpression() { + return delegate.isExpression(); + } - return prop.build(); - }, QueryTokens.TOKEN_COMMA)); + @Override + public int size() { + return delegate.size(); + } - } else { - builder.appendInline(selectionList); + @Override + public boolean isEmpty() { + return delegate.isEmpty(); } - builder.append(QueryTokens.TOKEN_CLOSE_PAREN); + @Override + public Iterator iterator() { + return delegate.iterator(); + } - return builder.build(); + @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 57% rename from spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyDeclaredQuery.java rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyIntrospectedQuery.java index 850c0919a3..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 @@ -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 index db498281fc..1b05738d5e 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyQueryTokenStream.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EmptyQueryTokenStream.java @@ -18,6 +18,8 @@ import java.util.Collections; import java.util.Iterator; +import org.jspecify.annotations.Nullable; + /** * Empty QueryTokenStream. * @@ -31,12 +33,12 @@ class EmptyQueryTokenStream implements QueryTokenStream { private EmptyQueryTokenStream() {} @Override - public QueryToken getFirst() { + public @Nullable QueryToken getFirst() { return null; } @Override - public QueryToken getLast() { + public @Nullable QueryToken getLast() { return null; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EntityQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EntityQuery.java new file mode 100644 index 0000000000..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 index 0221aff83a..9b4f06b381 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlCountQueryTransformer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlCountQueryTransformer.java @@ -17,9 +17,11 @@ import static org.springframework.data.jpa.repository.query.QueryTokens.*; +import org.jspecify.annotations.Nullable; + import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; import org.springframework.data.jpa.repository.query.QueryTransformers.CountSelectionTokenStream; -import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; /** * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed EQL query into a @@ -30,7 +32,7 @@ * @author Christoph Strobl * @since 3.4 */ -@SuppressWarnings("ConstantValue") +@SuppressWarnings({ "ConstantValue", "NullAway" }) class EqlCountQueryTransformer extends EqlQueryRenderer { private final @Nullable String countProjection; @@ -42,7 +44,7 @@ class EqlCountQueryTransformer extends EqlQueryRenderer { } @Override - public QueryRendererBuilder visitSelect_statement(EqlParser.Select_statementContext ctx) { + public QueryTokenStream visitSelectQuery(EqlParser.SelectQueryContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); @@ -62,6 +64,49 @@ public QueryRendererBuilder visitSelect_statement(EqlParser.Select_statementCont 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) { @@ -77,14 +122,21 @@ public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { if (usesDistinct) { nested.append(QueryTokens.expression(ctx.DISTINCT())); nested.append(getDistinctCountSelection(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA))); - } else { + } else if (StringUtils.hasText(primaryFromAlias)) { nested.append(QueryTokens.token(primaryFromAlias)); + } else { + 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 { - builder.append(QueryTokens.token(countProjection)); if (usesDistinct) { nested.append(QueryTokens.expression(ctx.DISTINCT())); } + nested.append(QueryTokens.token(countProjection)); } builder.appendInline(nested); @@ -92,7 +144,7 @@ public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { return builder; } - private QueryRendererBuilder getDistinctCountSelection(QueryTokenStream selectionListbuilder) { + private QueryTokenStream getDistinctCountSelection(QueryTokenStream selectionListbuilder) { QueryRendererBuilder nested = new QueryRendererBuilder(); CountSelectionTokenStream countSelection = CountSelectionTokenStream.create(selectionListbuilder); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryIntrospector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryIntrospector.java index 0f006f2388..71f65523b8 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryIntrospector.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlQueryIntrospector.java @@ -21,8 +21,7 @@ import java.util.Collections; import java.util.List; -import org.springframework.data.jpa.repository.query.EqlParser.Range_variable_declarationContext; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * {@link ParsedQueryIntrospector} for EQL queries. @@ -60,8 +59,9 @@ public Void visitSelect_clause(EqlParser.Select_clauseContext ctx) { @Override public Void visitRange_variable_declaration(EqlParser.Range_variable_declarationContext ctx) { - if (primaryFromAlias == null) { - primaryFromAlias = capturePrimaryAlias(ctx); + if (primaryFromAlias == null && ctx.identification_variable() != null && !EqlQueryRenderer.isSubquery(ctx) + && !EqlQueryRenderer.isSetQuery(ctx)) { + primaryFromAlias = ctx.identification_variable().getText(); } return super.visitRange_variable_declaration(ctx); @@ -74,11 +74,6 @@ public Void visitConstructor_expression(EqlParser.Constructor_expressionContext return super.visitConstructor_expression(ctx); } - private static String capturePrimaryAlias(Range_variable_declarationContext ctx) { - return ctx.identification_variable() != null ? ctx.identification_variable().getText() - : ctx.entity_name().getText(); - } - private static List captureSelectItems(List selections, EqlQueryRenderer itemRenderer) { @@ -93,4 +88,5 @@ private static List captureSelectItems(List { - @Override - public QueryTokenStream visitStart(EqlParser.StartContext ctx) { - return visit(ctx.ql_statement()); - } - - @Override - public QueryTokenStream 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 QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitSelect_statement(EqlParser.Select_statementContext 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())); - } + /** + * 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.groupby_clause() != null) { - builder.appendExpression(visit(ctx.groupby_clause())); - } - - if (ctx.having_clause() != null) { - builder.appendExpression(visit(ctx.having_clause())); - } - - if (ctx.orderby_clause() != null) { - builder.appendExpression(visit(ctx.orderby_clause())); - } - - for (int i = 0; i < ctx.setOperator().size(); i++) { - - builder.appendExpression(visit(ctx.setOperator(i))); - builder.appendExpression(visit(ctx.select_statement(i))); - } + while (ctx != null) { - return builder; - } - - @Override - public QueryTokenStream visitSetOperator(EqlParser.SetOperatorContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx instanceof EqlParser.SubqueryContext) { + return true; + } - if (ctx.UNION() != null) { - builder.append(QueryTokens.expression(ctx.UNION())); - } else if (ctx.INTERSECT() != null) { - builder.append(QueryTokens.expression(ctx.INTERSECT())); - } else if (ctx.EXCEPT() != null) { - builder.append(QueryTokens.expression(ctx.EXCEPT())); - } + if (ctx instanceof EqlParser.Update_statementContext || ctx instanceof EqlParser.Delete_statementContext) { + return false; + } - if (ctx.ALL() != null) { - builder.append(QueryTokens.expression(ctx.ALL())); + ctx = ctx.getParent(); } - return builder; + return false; } - @Override - public QueryTokenStream visitUpdate_statement(EqlParser.Update_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) { - QueryRendererBuilder builder = QueryRenderer.builder(); + while (ctx != null) { - builder.appendExpression(visit(ctx.update_clause())); + if (ctx instanceof EqlParser.Set_fuctionContext) { + return true; + } - if (ctx.where_clause() != null) { - builder.appendExpression(visit(ctx.where_clause())); + ctx = ctx.getParent(); } - return builder; + return false; } @Override - public QueryTokenStream visitDelete_statement(EqlParser.Delete_statementContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.delete_clause())); - - if (ctx.where_clause() != null) { - builder.appendExpression(visit(ctx.where_clause())); - } - - return builder; + public QueryTokenStream visitStart(EqlParser.StartContext ctx) { + return visit(ctx.ql_statement()); } @Override @@ -157,11 +110,7 @@ public QueryTokenStream visitFrom_clause(EqlParser.From_clauseContext ctx) { 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) { + if (ctx.subquery() != null) { QueryRendererBuilder nested = QueryRenderer.builder(); nested.append(TOKEN_OPEN_PAREN); @@ -170,130 +119,23 @@ public QueryTokenStream visitIdentificationVariableDeclarationOrCollectionMember QueryRendererBuilder builder = QueryRenderer.builder(); builder.appendExpression(nested); - builder.appendExpression(visit(ctx.identification_variable())); - - return builder; - } else { - return QueryRenderer.builder(); - } - } - - @Override - public QueryTokenStream visitIdentification_variable_declaration( - EqlParser.Identification_variable_declarationContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.range_variable_declaration())); - - ctx.join().forEach(joinContext -> { - builder.append(visit(joinContext)); - }); - ctx.fetch_join().forEach(fetchJoinContext -> { - builder.append(visit(fetchJoinContext)); - }); - - return builder; - } - - @Override - public QueryTokenStream visitRange_variable_declaration(EqlParser.Range_variable_declarationContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.entity_name() != null) { - builder.appendExpression(visit(ctx.entity_name())); - } else if (ctx.function_invocation() != null) { - builder.appendExpression(visit(ctx.function_invocation())); - } - - if (ctx.AS() != null) { - builder.append(QueryTokens.expression(ctx.AS())); - } - - builder.appendExpression(visit(ctx.identification_variable())); - - return builder; - } - - @Override - public QueryTokenStream visitJoin(EqlParser.JoinContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.join_spec())); - builder.appendExpression(visit(ctx.join_association_path_expression())); - - if (ctx.AS() != null) { - builder.append(QueryTokens.expression(ctx.AS())); - } - if (ctx.identification_variable() != null) { - builder.appendExpression(visit(ctx.identification_variable())); - } - if (ctx.join_condition() != null) { - builder.appendExpression(visit(ctx.join_condition())); - } - - return builder; - } - @Override - public QueryTokenStream visitFetch_join(EqlParser.Fetch_joinContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.join_spec())); - builder.append(QueryTokens.expression(ctx.FETCH())); - builder.appendExpression(visit(ctx.join_association_path_expression())); - - if (ctx.AS() != null) { - builder.append(QueryTokens.expression(ctx.AS())); - } - if (ctx.identification_variable() != null) { - builder.appendExpression(visit(ctx.identification_variable())); - } - if (ctx.join_condition() != null) { - builder.appendExpression(visit(ctx.join_condition())); - } - - return builder; - } - - @Override - public QueryTokenStream visitJoin_spec(EqlParser.Join_specContext ctx) { + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); + } - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.identification_variable() != null) { + builder.appendExpression(visit(ctx.identification_variable())); + } - if (ctx.LEFT() != null) { - builder.append(QueryTokens.expression(ctx.LEFT())); - } - if (ctx.OUTER() != null) { - builder.append(QueryTokens.expression(ctx.OUTER())); - } - if (ctx.INNER() != null) { - builder.append(QueryTokens.expression(ctx.INNER())); - } - if (ctx.JOIN() != null) { - builder.append(QueryTokens.expression(ctx.JOIN())); + return builder; } - return builder; - } - - @Override - public QueryTokenStream visitJoin_condition(EqlParser.Join_conditionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.ON())); - builder.appendExpression(visit(ctx.conditional_expression())); - - return builder; + return super.visitIdentificationVariableDeclarationOrCollectionMemberDeclaration(ctx); } @Override - public QueryTokenStream visitJoin_association_path_expression( - EqlParser.Join_association_path_expressionContext ctx) { + public QueryTokenStream visitJoin_association_path_expression(EqlParser.Join_association_path_expressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); @@ -305,31 +147,25 @@ public QueryTokenStream visitJoin_association_path_expression( builder.appendExpression(visit(ctx.join_single_valued_path_expression())); } } else { - if (ctx.join_collection_valued_path_expression() != null) { + QueryRendererBuilder nested = QueryRenderer.builder(); - QueryRendererBuilder nested = QueryRenderer.builder(); + if (ctx.join_collection_valued_path_expression() != null) { - nested.append(QueryTokens.token(ctx.TREAT())); - nested.append(TOKEN_OPEN_PAREN); - nested.appendInline(visit(ctx.join_collection_valued_path_expression())); + nested.appendExpression(visit(ctx.join_collection_valued_path_expression())); nested.append(QueryTokens.expression(ctx.AS())); - nested.appendInline(visit(ctx.subtype())); - nested.append(TOKEN_CLOSE_PAREN); + nested.appendExpression(visit(ctx.subtype())); - builder.appendExpression(nested); } else if (ctx.join_single_valued_path_expression() != null) { - QueryRendererBuilder nested = QueryRenderer.builder(); - - nested.append(QueryTokens.token(ctx.TREAT())); - nested.append(TOKEN_OPEN_PAREN); - nested.appendInline(visit(ctx.join_single_valued_path_expression())); + nested.appendExpression(visit(ctx.join_single_valued_path_expression())); nested.append(QueryTokens.expression(ctx.AS())); - nested.appendInline(visit(ctx.subtype())); - nested.append(TOKEN_CLOSE_PAREN); - - builder.appendExpression(nested); + nested.appendExpression(visit(ctx.subtype())); } + + builder.append(QueryTokens.token(ctx.TREAT())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); } return builder; @@ -340,6 +176,7 @@ public QueryTokenStream visitJoin_collection_valued_path_expression( EqlParser.Join_collection_valued_path_expressionContext ctx) { List items = new ArrayList<>(2 + ctx.single_valued_embeddable_object_field().size()); + if (ctx.identification_variable() != null) { items.add(ctx.identification_variable()); } @@ -368,18 +205,23 @@ public QueryTokenStream visitJoin_single_valued_path_expression( @Override public QueryTokenStream visitCollection_member_declaration(EqlParser.Collection_member_declarationContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.IN())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.collection_valued_path_expression())); - builder.append(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) { builder.append(QueryTokens.expression(ctx.AS())); } - builder.appendExpression(visit(ctx.identification_variable())); + if (ctx.identification_variable() != null) { + builder.appendExpression(visit(ctx.identification_variable())); + } return builder; } @@ -450,41 +292,28 @@ public QueryTokenStream visitSingle_valued_path_expression(EqlParser.Single_valu return builder; } - @Override - public QueryTokenStream visitGeneral_identification_variable( - EqlParser.General_identification_variableContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.identification_variable() != null) { - builder.append(visit(ctx.identification_variable())); - } else if (ctx.map_field_identification_variable() != null) { - builder.append(visit(ctx.map_field_identification_variable())); - } - - return builder; - } - @Override public QueryTokenStream visitGeneral_subpath(EqlParser.General_subpathContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.simple_subpath() != null) { - builder.appendInline(visit(ctx.simple_subpath())); + return visit(ctx.simple_subpath()); } else if (ctx.treated_subpath() != null) { - builder.appendInline(visit(ctx.treated_subpath())); - builder.appendInline(QueryTokenStream.concat(ctx.single_valued_object_field(), this::visit, TOKEN_DOT)); + List items = new ArrayList<>(1 + ctx.single_valued_object_field().size()); + + items.add(ctx.treated_subpath()); + items.addAll(ctx.single_valued_object_field()); + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } - return builder; + return QueryTokenStream.empty(); } @Override public QueryTokenStream visitSimple_subpath(EqlParser.Simple_subpathContext ctx) { List items = new ArrayList<>(1 + ctx.single_valued_object_field().size()); + items.add(ctx.general_identification_variable()); items.addAll(ctx.single_valued_object_field()); @@ -495,12 +324,15 @@ public QueryTokenStream visitSimple_subpath(EqlParser.Simple_subpathContext ctx) public QueryTokenStream visitTreated_subpath(EqlParser.Treated_subpathContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); + + nested.appendExpression(visit(ctx.general_subpath())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.subtype())); builder.append(QueryTokens.token(ctx.TREAT())); builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.general_subpath())); - builder.append(QueryTokens.expression(ctx.AS())); - builder.appendInline(visit(ctx.subtype())); + builder.appendInline(nested); builder.append(TOKEN_CLOSE_PAREN); return builder; @@ -518,20 +350,6 @@ public QueryTokenStream visitState_field_path_expression(EqlParser.State_field_p return builder; } - @Override - public QueryTokenStream visitState_valued_path_expression(EqlParser.State_valued_path_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_field_path_expression() != null) { - builder.append(visit(ctx.state_field_path_expression())); - } else if (ctx.general_identification_variable() != null) { - builder.append(visit(ctx.general_identification_variable())); - } - - return builder; - } - @Override public QueryTokenStream visitSingle_valued_object_path_expression( EqlParser.Single_valued_object_path_expressionContext ctx) { @@ -575,7 +393,7 @@ public QueryTokenStream visitUpdate_clause(EqlParser.Update_clauseContext ctx) { } builder.append(QueryTokens.expression(ctx.SET())); - builder.appendExpression(QueryTokenStream.concat(ctx.update_item(), this::visit, TOKEN_COMMA)); + builder.append(QueryTokenStream.concat(ctx.update_item(), this::visit, TOKEN_COMMA)); return builder; } @@ -586,6 +404,7 @@ public QueryTokenStream visitUpdate_item(EqlParser.Update_itemContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); List items = new ArrayList<>(3 + ctx.single_valued_embeddable_object_field().size()); + if (ctx.identification_variable() != null) { items.add(ctx.identification_variable()); } @@ -606,40 +425,16 @@ public QueryTokenStream visitUpdate_item(EqlParser.Update_itemContext ctx) { } @Override - public QueryTokenStream 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 QueryRendererBuilder.from(QueryTokens.expression(ctx.NULL())); - } else { - return QueryRenderer.builder(); - } - } - - @Override - public QueryTokenStream visitDelete_clause(EqlParser.Delete_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { - builder.append(QueryTokens.expression(ctx.DELETE())); - builder.append(QueryTokens.expression(ctx.FROM())); - builder.appendExpression(visit(ctx.entity_name())); + QueryRendererBuilder builder = prepareSelectClause(ctx); - if (ctx.AS() != null) { - builder.append(QueryTokens.expression(ctx.AS())); - } - if (ctx.identification_variable() != null) { - builder.appendExpression(visit(ctx.identification_variable())); - } + builder.appendExpression(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA)); return builder; } - @Override - public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { + QueryRendererBuilder prepareSelectClause(EqlParser.Select_clauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); @@ -649,58 +444,25 @@ public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { builder.append(QueryTokens.expression(ctx.DISTINCT())); } - builder.appendExpression(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA)); - - return builder; - } - - @Override - public QueryTokenStream visitSelect_item(EqlParser.Select_itemContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.select_expression())); - - if (ctx.AS() != null) { - builder.append(QueryTokens.expression(ctx.AS())); - } - - if (ctx.result_variable() != null) { - builder.appendExpression(visit(ctx.result_variable())); - } - return builder; } @Override public QueryTokenStream 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) { - - if (ctx.OBJECT() == null) { - return visit(ctx.identification_variable()); - } else { + if (ctx.identification_variable() != null && ctx.OBJECT() != null) { - QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.OBJECT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.identification_variable())); - builder.append(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 builder; - } - } else if (ctx.constructor_expression() != null) { - return visit(ctx.constructor_expression()); - } else { - return QueryRenderer.builder(); + return builder; } + + return super.visitSelect_expression(ctx); } @Override @@ -717,24 +479,6 @@ public QueryTokenStream visitConstructor_expression(EqlParser.Constructor_expres return builder; } - @Override - public QueryTokenStream visitConstructor_item(EqlParser.Constructor_itemContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.single_valued_path_expression() != null) { - builder.append(visit(ctx.single_valued_path_expression())); - } else if (ctx.scalar_expression() != null) { - builder.append(visit(ctx.scalar_expression())); - } else if (ctx.aggregate_expression() != null) { - builder.append(visit(ctx.aggregate_expression())); - } else if (ctx.identification_variable() != null) { - builder.append(visit(ctx.identification_variable())); - } - - return builder; - } - @Override public QueryTokenStream visitAggregate_expression(EqlParser.Aggregate_expressionContext ctx) { @@ -761,7 +505,7 @@ public QueryTokenStream visitAggregate_expression(EqlParser.Aggregate_expression builder.append(QueryTokens.expression(ctx.DISTINCT())); } - builder.appendInline(visit(ctx.state_valued_path_expression())); + builder.appendInline(visit(ctx.simple_select_expression())); builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.COUNT() != null) { @@ -770,13 +514,7 @@ public QueryTokenStream visitAggregate_expression(EqlParser.Aggregate_expression if (ctx.DISTINCT() != null) { builder.append(QueryTokens.expression(ctx.DISTINCT())); } - if (ctx.identification_variable() != null) { - builder.appendInline(visit(ctx.identification_variable())); - } else if (ctx.state_valued_path_expression() != null) { - builder.appendInline(visit(ctx.state_valued_path_expression())); - } else if (ctx.single_valued_object_path_expression() != null) { - builder.appendInline(visit(ctx.single_valued_object_path_expression())); - } + builder.appendInline(visit(ctx.simple_select_expression())); builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.function_invocation() != null) { builder.append(visit(ctx.function_invocation())); @@ -786,747 +524,112 @@ public QueryTokenStream visitAggregate_expression(EqlParser.Aggregate_expression } @Override - public QueryTokenStream visitWhere_clause(EqlParser.Where_clauseContext ctx) { + public QueryTokenStream visitGroupby_clause(EqlParser.Groupby_clauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.WHERE())); - builder.appendExpression(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 builder; } @Override - public QueryTokenStream visitGroupby_clause(EqlParser.Groupby_clauseContext ctx) { + public QueryTokenStream visitOrderby_clause(EqlParser.Orderby_clauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.GROUP())); + builder.append(QueryTokens.expression(ctx.ORDER())); builder.append(QueryTokens.expression(ctx.BY())); - builder.appendExpression(QueryTokenStream.concat(ctx.groupby_item(), this::visit, TOKEN_COMMA)); + builder.append(QueryTokenStream.concat(ctx.orderby_item(), this::visit, TOKEN_COMMA)); return builder; } @Override - public QueryTokenStream visitGroupby_item(EqlParser.Groupby_itemContext ctx) { + public QueryTokenStream visitSubquery_from_clause(EqlParser.Subquery_from_clauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.single_valued_path_expression() != null) { - builder.append(visit(ctx.single_valued_path_expression())); - } else if (ctx.identification_variable() != null) { - builder.append(visit(ctx.identification_variable())); - } else if (ctx.scalar_expression() != null) { - builder.append(visit(ctx.scalar_expression())); - } + builder.append(QueryTokens.expression(ctx.FROM())); + builder.appendExpression( + QueryTokenStream.concat(ctx.subselect_identification_variable_declaration(), this::visit, TOKEN_COMMA)); return builder; } @Override - public QueryTokenStream visitHaving_clause(EqlParser.Having_clauseContext ctx) { + public QueryTokenStream visitConditional_primary(EqlParser.Conditional_primaryContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.HAVING())); - builder.appendExpression(visit(ctx.conditional_expression())); - - return builder; - } - - @Override - public QueryTokenStream visitOrderby_clause(EqlParser.Orderby_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.ORDER())); - builder.append(QueryTokens.expression(ctx.BY())); - builder.append(QueryTokenStream.concat(ctx.orderby_item(), this::visit, TOKEN_COMMA)); - - return builder; - } - - @Override - public QueryTokenStream visitOrderby_item(EqlParser.Orderby_itemContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_field_path_expression() != null) { - builder.append(visit(ctx.state_field_path_expression())); - } else if (ctx.general_identification_variable() != null) { - builder.append(visit(ctx.general_identification_variable())); - } else if (ctx.result_variable() != null) { - builder.append(visit(ctx.result_variable())); - } else if (ctx.string_expression() != null) { - builder.append(visit(ctx.string_expression())); - } else if (ctx.scalar_expression() != null) { - builder.append(visit(ctx.scalar_expression())); - } - - if (ctx.ASC() != null) { - builder.append(QueryTokens.expression(ctx.ASC())); - } - if (ctx.DESC() != null) { - builder.append(QueryTokens.expression(ctx.DESC())); - } - - if (ctx.nullsPrecedence() != null) { - builder.appendExpression(visit(ctx.nullsPrecedence())); - } - - return builder; - } - - @Override - public QueryTokenStream visitNullsPrecedence(EqlParser.NullsPrecedenceContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(TOKEN_NULLS); - - if (ctx.FIRST() != null) { - builder.append(TOKEN_FIRST); - } else if (ctx.LAST() != null) { - builder.append(TOKEN_LAST); - } - - return builder; - } - - @Override - public QueryTokenStream visitSubquery(EqlParser.SubqueryContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.simple_select_clause())); - builder.appendExpression(visit(ctx.subquery_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 visitSubquery_from_clause(EqlParser.Subquery_from_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.FROM())); - builder.appendExpression( - QueryTokenStream.concat(ctx.subselect_identification_variable_declaration(), this::visit, TOKEN_COMMA)); - - return builder; - } - - @Override - public QueryTokenStream visitSubselect_identification_variable_declaration( - EqlParser.Subselect_identification_variable_declarationContext ctx) { - return super.visitSubselect_identification_variable_declaration(ctx); - } - - @Override - public QueryTokenStream visitDerived_path_expression(EqlParser.Derived_path_expressionContext ctx) { - return super.visitDerived_path_expression(ctx); - } - - @Override - public QueryTokenStream visitGeneral_derived_path(EqlParser.General_derived_pathContext ctx) { - return super.visitGeneral_derived_path(ctx); - } - - @Override - public QueryTokenStream visitSimple_derived_path(EqlParser.Simple_derived_pathContext ctx) { - return super.visitSimple_derived_path(ctx); - } - - @Override - public QueryTokenStream visitTreated_derived_path(EqlParser.Treated_derived_pathContext ctx) { - return super.visitTreated_derived_path(ctx); - } - - @Override - public QueryTokenStream visitDerived_collection_member_declaration( - EqlParser.Derived_collection_member_declarationContext ctx) { - return super.visitDerived_collection_member_declaration(ctx); - } - - @Override - public QueryTokenStream visitSimple_select_clause(EqlParser.Simple_select_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.SELECT())); - if (ctx.DISTINCT() != null) { - builder.append(QueryTokens.expression(ctx.DISTINCT())); - } - builder.append(visit(ctx.simple_select_expression())); - - return builder; - } - - @Override - public QueryTokenStream visitSimple_select_expression(EqlParser.Simple_select_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.single_valued_path_expression() != null) { - builder.append(visit(ctx.single_valued_path_expression())); - } else if (ctx.scalar_expression() != null) { - builder.append(visit(ctx.scalar_expression())); - } else if (ctx.aggregate_expression() != null) { - builder.append(visit(ctx.aggregate_expression())); - } else if (ctx.identification_variable() != null) { - builder.append(visit(ctx.identification_variable())); - } - - return builder; - } - - @Override - public QueryTokenStream visitScalar_expression(EqlParser.Scalar_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.arithmetic_expression() != null) { - builder.append(visit(ctx.arithmetic_expression())); - } else if (ctx.string_expression() != null) { - builder.append(visit(ctx.string_expression())); - } else if (ctx.enum_expression() != null) { - builder.append(visit(ctx.enum_expression())); - } else if (ctx.datetime_expression() != null) { - builder.append(visit(ctx.datetime_expression())); - } else if (ctx.boolean_expression() != null) { - builder.append(visit(ctx.boolean_expression())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.entity_type_expression() != null) { - builder.append(visit(ctx.entity_type_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitConditional_expression(EqlParser.Conditional_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.conditional_expression() != null) { - builder.append(visit(ctx.conditional_expression())); - builder.append(QueryTokens.expression(ctx.OR())); - builder.append(visit(ctx.conditional_term())); - } else { - builder.append(visit(ctx.conditional_term())); - } - - return builder; - } - - @Override - public QueryTokenStream visitConditional_term(EqlParser.Conditional_termContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.conditional_term() != null) { - builder.append(visit(ctx.conditional_term())); - builder.append(QueryTokens.expression(ctx.AND())); - builder.append(visit(ctx.conditional_factor())); - } else { - builder.append(visit(ctx.conditional_factor())); - } - - return builder; - } - - @Override - public QueryTokenStream visitConditional_factor(EqlParser.Conditional_factorContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(visit(ctx.conditional_primary())); - - return builder; - } - - @Override - public QueryTokenStream visitConditional_primary(EqlParser.Conditional_primaryContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.simple_cond_expression() != null) { - builder.append(visit(ctx.simple_cond_expression())); - } else if (ctx.conditional_expression() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.conditional_expression())); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitSimple_cond_expression(EqlParser.Simple_cond_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.comparison_expression() != null) { - builder.append(visit(ctx.comparison_expression())); - } else if (ctx.between_expression() != null) { - builder.append(visit(ctx.between_expression())); - } else if (ctx.in_expression() != null) { - builder.append(visit(ctx.in_expression())); - } else if (ctx.like_expression() != null) { - builder.append(visit(ctx.like_expression())); - } else if (ctx.null_comparison_expression() != null) { - builder.append(visit(ctx.null_comparison_expression())); - } else if (ctx.empty_collection_comparison_expression() != null) { - builder.append(visit(ctx.empty_collection_comparison_expression())); - } else if (ctx.collection_member_expression() != null) { - builder.append(visit(ctx.collection_member_expression())); - } else if (ctx.exists_expression() != null) { - builder.append(visit(ctx.exists_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitBetween_expression(EqlParser.Between_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.arithmetic_expression(0) != null) { - - builder.append(visit(ctx.arithmetic_expression(0))); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(QueryTokens.expression(ctx.BETWEEN())); - builder.appendExpression(visit(ctx.arithmetic_expression(1))); - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.arithmetic_expression(2))); - - } else if (ctx.string_expression(0) != null) { - - builder.appendExpression(visit(ctx.string_expression(0))); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(QueryTokens.expression(ctx.BETWEEN())); - builder.appendExpression(visit(ctx.string_expression(1))); - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.string_expression(2))); - - } else if (ctx.datetime_expression(0) != null) { - - builder.append(visit(ctx.datetime_expression(0))); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(QueryTokens.expression(ctx.BETWEEN())); - builder.appendExpression(visit(ctx.datetime_expression(1))); - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.datetime_expression(2))); - } - - return builder; - } - - @Override - public QueryTokenStream visitIn_expression(EqlParser.In_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.string_expression() != null) { - builder.append(visit(ctx.string_expression())); - } - if (ctx.type_discriminator() != null) { - builder.append(visit(ctx.type_discriminator())); - } - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - if (ctx.IN() != null) { - builder.append(QueryTokens.expression(ctx.IN())); - } - - if (ctx.in_item() != null && !ctx.in_item().isEmpty()) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(QueryTokenStream.concat(ctx.in_item(), this::visit, TOKEN_COMMA)); - - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.collection_valued_input_parameter() != null) { - builder.append(visit(ctx.collection_valued_input_parameter())); - } - - return builder; - } - - @Override - public QueryTokenStream visitIn_item(EqlParser.In_itemContext ctx) { - - if (ctx.literal() != null) { - return visit(ctx.literal()); - } else if (ctx.string_expression() != null) { - return visit(ctx.string_expression()); - } else if (ctx.boolean_literal() != null) { - return visit(ctx.boolean_literal()); - } else if (ctx.numeric_literal() != null) { - return visit(ctx.numeric_literal()); - } else if (ctx.date_time_timestamp_literal() != null) { - return visit(ctx.date_time_timestamp_literal()); - } else if (ctx.single_valued_input_parameter() != null) { - return visit(ctx.single_valued_input_parameter()); - } else if (ctx.conditional_expression() != null) { - return visit(ctx.conditional_expression()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitLike_expression(EqlParser.Like_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.string_expression())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - builder.append(QueryTokens.expression(ctx.LIKE())); - builder.appendExpression(visit(ctx.pattern_value())); - - if (ctx.ESCAPE() != null) { - - builder.append(QueryTokens.expression(ctx.ESCAPE())); - builder.appendExpression(visit(ctx.escape_character())); - } - - return builder; - } - - @Override - public QueryTokenStream visitNull_comparison_expression(EqlParser.Null_comparison_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.single_valued_path_expression() != null) { - builder.append(visit(ctx.single_valued_path_expression())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.nullif_expression() != null) { - builder.append(visit(ctx.nullif_expression())); - } - - if (ctx.op != null) { - builder.append(QueryTokens.expression(ctx.op.getText())); - } else { - builder.append(QueryTokens.expression(ctx.IS())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - } - builder.append(QueryTokens.expression(ctx.NULL())); - - return builder; - } - - @Override - public QueryTokenStream visitEmpty_collection_comparison_expression( - EqlParser.Empty_collection_comparison_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.collection_valued_path_expression())); - builder.append(QueryTokens.expression(ctx.IS())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - builder.append(QueryTokens.expression(ctx.EMPTY())); - - return builder; - } - - @Override - public QueryTokenStream visitCollection_member_expression(EqlParser.Collection_member_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.entity_or_value_expression())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - builder.append(QueryTokens.expression(ctx.MEMBER())); - if (ctx.OF() != null) { - builder.append(QueryTokens.expression(ctx.OF())); - } - builder.append(visit(ctx.collection_valued_path_expression())); - - return builder; - } - - @Override - public QueryTokenStream visitEntity_or_value_expression(EqlParser.Entity_or_value_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.single_valued_object_path_expression() != null) { - builder.append(visit(ctx.single_valued_object_path_expression())); - } else if (ctx.state_field_path_expression() != null) { - builder.append(visit(ctx.state_field_path_expression())); - } else if (ctx.simple_entity_or_value_expression() != null) { - builder.append(visit(ctx.simple_entity_or_value_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitSimple_entity_or_value_expression( - EqlParser.Simple_entity_or_value_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.identification_variable() != null) { - return visit(ctx.identification_variable()); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } else if (ctx.literal() != null) { - return visit(ctx.literal()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitExists_expression(EqlParser.Exists_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(QueryTokens.expression(ctx.EXISTS())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitAll_or_any_expression(EqlParser.All_or_any_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - 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())); - } - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitStringComparison(EqlParser.StringComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.string_expression(0))); - builder.append(visit(ctx.comparison_operator())); - - if (ctx.string_expression(1) != null) { - builder.append(visit(ctx.string_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitBooleanComparison(EqlParser.BooleanComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.boolean_expression(0))); - builder.append(QueryTokens.ventilated(ctx.op)); - - if (ctx.boolean_expression(1) != null) { - builder.append(visit(ctx.boolean_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); + if (ctx.conditional_expression() != null) { + return QueryTokenStream.group(visit(ctx.conditional_expression())); } - return builder; - } - - @Override - public QueryTokenStream visitDirectBooleanCheck(EqlParser.DirectBooleanCheckContext ctx) { - return visit(ctx.boolean_expression()); + return super.visitConditional_primary(ctx); } @Override - public QueryTokenStream visitEnumComparison(EqlParser.EnumComparisonContext ctx) { + public QueryTokenStream visitIn_expression(EqlParser.In_expressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendInline(visit(ctx.enum_expression(0))); - builder.append(QueryTokens.ventilated(ctx.op)); - - if (ctx.enum_expression(1) != null) { - builder.append(visit(ctx.enum_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); + if (ctx.string_expression() != null) { + builder.appendExpression(visit(ctx.string_expression())); } - return builder; - } - - @Override - public QueryTokenStream visitDatetimeComparison(EqlParser.DatetimeComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.datetime_expression(0))); - builder.append(QueryTokens.ventilated(ctx.comparison_operator().op)); - - if (ctx.datetime_expression(1) != null) { - builder.append(visit(ctx.datetime_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); + if (ctx.type_discriminator() != null) { + builder.appendExpression(visit(ctx.type_discriminator())); } - return builder; - } - - @Override - public QueryTokenStream visitEntityComparison(EqlParser.EntityComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.entity_expression(0))); - builder.append(QueryTokens.expression(ctx.op)); - - if (ctx.entity_expression(1) != null) { - builder.append(visit(ctx.entity_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); + if (ctx.NOT() != null) { + builder.append(QueryTokens.expression(ctx.NOT())); } - return builder; - } - - @Override - public QueryTokenStream visitArithmeticComparison(EqlParser.ArithmeticComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.arithmetic_expression(0))); - builder.append(visit(ctx.comparison_operator())); + if (ctx.IN() != null) { + builder.append(QueryTokens.expression(ctx.IN())); + } - if (ctx.arithmetic_expression(1) != null) { - builder.append(visit(ctx.arithmetic_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); + 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 builder; } @Override - public QueryTokenStream visitEntityTypeComparison(EqlParser.EntityTypeComparisonContext ctx) { + public QueryTokenStream visitExists_expression(EqlParser.Exists_expressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendInline(visit(ctx.entity_type_expression(0))); - builder.append(QueryTokens.ventilated(ctx.op)); - builder.append(visit(ctx.entity_type_expression(1))); - - return builder; - } - - @Override - public QueryTokenStream visitRegexpComparison(EqlParser.RegexpComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.NOT() != null) { + builder.append(QueryTokens.expression(ctx.NOT())); + } - builder.appendExpression(visit(ctx.string_expression())); - builder.append(QueryTokens.expression(ctx.REGEXP())); - builder.appendExpression(visit(ctx.string_literal())); + builder.append(QueryTokens.expression(ctx.EXISTS())); + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); return builder; } @Override - public QueryTokenStream visitComparison_operator(EqlParser.Comparison_operatorContext ctx) { - return QueryRendererBuilder.from(QueryTokens.ventilated(ctx.op)); - } - - @Override - public QueryTokenStream visitArithmetic_expression(EqlParser.Arithmetic_expressionContext ctx) { + public QueryTokenStream visitAll_or_any_expression(EqlParser.All_or_any_expressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.arithmetic_expression() != null) { - - builder.append(visit(ctx.arithmetic_expression())); - builder.append(QueryTokens.expression(ctx.op)); - builder.append(visit(ctx.arithmetic_term())); - - } else { - builder.append(visit(ctx.arithmetic_term())); + 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 builder; - } - - @Override - public QueryTokenStream visitArithmetic_term(EqlParser.Arithmetic_termContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.arithmetic_term() != null) { - - builder.appendInline(visit(ctx.arithmetic_term())); - builder.append(QueryTokens.ventilated(ctx.op)); - builder.append(visit(ctx.arithmetic_factor())); - } else { - builder.append(visit(ctx.arithmetic_factor())); - } + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); return builder; } @@ -1539,7 +642,8 @@ public QueryTokenStream visitArithmetic_factor(EqlParser.Arithmetic_factorContex if (ctx.op != null) { builder.append(QueryTokens.token(ctx.op)); } - builder.appendInline(visit(ctx.arithmetic_primary())); + + builder.append(visit(ctx.arithmetic_primary())); return builder; } @@ -1547,195 +651,53 @@ public QueryTokenStream visitArithmetic_factor(EqlParser.Arithmetic_factorContex @Override public QueryTokenStream visitArithmetic_primary(EqlParser.Arithmetic_primaryContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.numeric_literal() != null) { - builder.append(visit(ctx.numeric_literal())); - } else if (ctx.arithmetic_expression() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression())); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_numerics() != null) { - builder.append(visit(ctx.functions_returning_numerics())); - } else if (ctx.aggregate_expression() != null) { - builder.append(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.arithmetic_cast_function() != null) { - builder.append(visit(ctx.arithmetic_cast_function())); - } else if (ctx.type_cast_function() != null) { - builder.append(visit(ctx.type_cast_function())); - } else if (ctx.function_invocation() != null) { - builder.append(visit(ctx.function_invocation())); + if (ctx.arithmetic_expression() != null) { + return QueryTokenStream.group(visit(ctx.arithmetic_expression())); } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.group(visit(ctx.subquery())); } - return builder; + return super.visitArithmetic_primary(ctx); } @Override public QueryTokenStream visitString_expression(EqlParser.String_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.string_literal() != null) { - builder.append(visit(ctx.string_literal())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_strings() != null) { - builder.append(visit(ctx.functions_returning_strings())); - } else if (ctx.aggregate_expression() != null) { - builder.append(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.string_cast_function() != null) { - builder.append(visit(ctx.string_cast_function())); - } else if (ctx.type_cast_function() != null) { - builder.append(visit(ctx.type_cast_function())); - } else if (ctx.function_invocation() != null) { - builder.append(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return builder; + return super.visitString_expression(ctx); } @Override public QueryTokenStream visitDatetime_expression(EqlParser.Datetime_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_datetime() != null) { - builder.append(visit(ctx.functions_returning_datetime())); - } else if (ctx.aggregate_expression() != null) { - builder.append(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - builder.append(visit(ctx.function_invocation())); - } else if (ctx.date_time_timestamp_literal() != null) { - builder.append(visit(ctx.date_time_timestamp_literal())); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return builder; + return super.visitDatetime_expression(ctx); } @Override public QueryTokenStream visitBoolean_expression(EqlParser.Boolean_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.boolean_literal() != null) { - builder.append(visit(ctx.boolean_literal())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - builder.append(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return builder; + return super.visitBoolean_expression(ctx); } @Override public QueryTokenStream visitEnum_expression(EqlParser.Enum_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.enum_literal() != null) { - builder.append(visit(ctx.enum_literal())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitEntity_expression(EqlParser.Entity_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.single_valued_object_path_expression() != null) { - builder.append(visit(ctx.single_valued_object_path_expression())); - } else if (ctx.simple_entity_expression() != null) { - builder.append(visit(ctx.simple_entity_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitSimple_entity_expression(EqlParser.Simple_entity_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.identification_variable() != null) { - builder.append(visit(ctx.identification_variable())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } - - return builder; - } - - @Override - public QueryTokenStream visitEntity_type_expression(EqlParser.Entity_type_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.type_discriminator() != null) { - builder.append(visit(ctx.type_discriminator())); - } else if (ctx.entity_type_literal() != null) { - builder.append(visit(ctx.entity_type_literal())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return builder; + return super.visitEnum_expression(ctx); } @Override @@ -1743,19 +705,15 @@ public QueryTokenStream visitType_discriminator(EqlParser.Type_discriminatorCont QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.TYPE())); - builder.append(TOKEN_OPEN_PAREN); - if (ctx.general_identification_variable() != null) { - builder.appendInline(visit(ctx.general_identification_variable())); + builder.append(visit(ctx.general_identification_variable())); } else if (ctx.single_valued_object_path_expression() != null) { - builder.appendInline(visit(ctx.single_valued_object_path_expression())); + builder.append(visit(ctx.single_valued_object_path_expression())); } else if (ctx.input_parameter() != null) { - builder.appendInline(visit(ctx.input_parameter())); + builder.append(visit(ctx.input_parameter())); } - builder.append(TOKEN_CLOSE_PAREN); - return builder; + return QueryTokenStream.ofFunction(ctx.TYPE(), builder); } @Override @@ -1764,132 +722,60 @@ public QueryTokenStream visitFunctions_returning_numerics(EqlParser.Functions_re QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.LENGTH() != null) { - - builder.append(QueryTokens.token(ctx.LENGTH())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.string_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.LENGTH(), visit(ctx.string_expression(0))); } else if (ctx.LOCATE() != null) { - builder.append(QueryTokens.token(ctx.LOCATE())); - builder.append(TOKEN_OPEN_PAREN); builder.appendInline(visit(ctx.string_expression(0))); builder.append(TOKEN_COMMA); builder.appendInline(visit(ctx.string_expression(1))); + if (ctx.arithmetic_expression() != null) { builder.append(TOKEN_COMMA); builder.appendInline(visit(ctx.arithmetic_expression(0))); } - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.ABS() != null) { - builder.append(QueryTokens.token(ctx.ABS())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + 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) { - - builder.append(QueryTokens.token(ctx.CEILING())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.CEILING(), visit(ctx.arithmetic_expression(0))); } else if (ctx.EXP() != null) { - - builder.append(QueryTokens.token(ctx.EXP())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.EXP(), visit(ctx.arithmetic_expression(0))); } else if (ctx.FLOOR() != null) { - - builder.append(QueryTokens.token(ctx.FLOOR())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.FLOOR(), visit(ctx.arithmetic_expression(0))); } else if (ctx.LN() != null) { - - builder.append(QueryTokens.token(ctx.LN())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.LN(), visit(ctx.arithmetic_expression(0))); } else if (ctx.SIGN() != null) { - - builder.append(QueryTokens.token(ctx.SIGN())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.SIGN(), visit(ctx.arithmetic_expression(0))); } else if (ctx.SQRT() != null) { - - builder.append(QueryTokens.token(ctx.SQRT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.SQRT(), visit(ctx.arithmetic_expression(0))); } else if (ctx.MOD() != null) { - builder.append(QueryTokens.token(ctx.MOD())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_COMMA); - builder.appendInline(visit(ctx.arithmetic_expression(1))); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.POWER() != null) { - - builder.append(QueryTokens.token(ctx.POWER())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_COMMA); - builder.appendInline(visit(ctx.arithmetic_expression(1))); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.ROUND() != null) { - - builder.append(QueryTokens.token(ctx.ROUND())); - builder.append(TOKEN_OPEN_PAREN); builder.appendInline(visit(ctx.arithmetic_expression(0))); builder.append(TOKEN_COMMA); builder.appendInline(visit(ctx.arithmetic_expression(1))); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.SIZE() != null) { - - builder.append(QueryTokens.token(ctx.SIZE())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.collection_valued_path_expression())); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.INDEX() != null) { - builder.append(QueryTokens.token(ctx.INDEX())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.identification_variable())); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.extract_datetime_field() != null) { - builder.append(visit(ctx.extract_datetime_field())); - } + return QueryTokenStream.ofFunction(ctx.MOD(), builder); + } else if (ctx.POWER() != null) { - return builder; - } + builder.appendInline(visit(ctx.arithmetic_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(1))); - @Override - public QueryTokenStream visitFunctions_returning_datetime(EqlParser.Functions_returning_datetimeContext ctx) { + return QueryTokenStream.ofFunction(ctx.POWER(), builder); + } else if (ctx.ROUND() != null) { - QueryRendererBuilder builder = QueryRenderer.builder(); + builder.appendInline(visit(ctx.arithmetic_expression(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.arithmetic_expression(1))); - if (ctx.CURRENT_DATE() != null) { - builder.append(QueryTokens.expression(ctx.CURRENT_DATE())); - } else if (ctx.CURRENT_TIME() != null) { - builder.append(QueryTokens.expression(ctx.CURRENT_TIME())); - } else if (ctx.CURRENT_TIMESTAMP() != null) { - builder.append(QueryTokens.expression(ctx.CURRENT_TIMESTAMP())); - } else if (ctx.LOCAL() != null) { - - builder.append(QueryTokens.expression(ctx.LOCAL())); - - if (ctx.DATE() != null) { - builder.append(QueryTokens.expression(ctx.DATE())); - } else if (ctx.TIME() != null) { - builder.append(QueryTokens.expression(ctx.TIME())); - } else if (ctx.DATETIME() != null) { - builder.append(QueryTokens.expression(ctx.DATETIME())); - } - } else if (ctx.extract_datetime_part() != null) { - builder.append(visit(ctx.extract_datetime_part())); + return 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 builder; @@ -1901,23 +787,17 @@ public QueryTokenStream visitFunctions_returning_strings(EqlParser.Functions_ret QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.CONCAT() != null) { - - builder.append(QueryTokens.token(ctx.CONCAT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.CONCAT(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); } else if (ctx.SUBSTRING() != null) { - builder.append(QueryTokens.token(ctx.SUBSTRING())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.string_expression(0))); + builder.append(visit(ctx.string_expression(0))); builder.append(TOKEN_COMMA); builder.appendInline(QueryTokenStream.concat(ctx.arithmetic_expression(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); + + return QueryTokenStream.ofFunction(ctx.SUBSTRING(), builder); } else if (ctx.TRIM() != null) { - builder.append(QueryTokens.token(ctx.TRIM())); - builder.append(TOKEN_OPEN_PAREN); if (ctx.trim_specification() != null) { builder.appendExpression(visit(ctx.trim_specification())); } @@ -1927,35 +807,36 @@ public QueryTokenStream visitFunctions_returning_strings(EqlParser.Functions_ret if (ctx.FROM() != null) { builder.append(QueryTokens.expression(ctx.FROM())); } - builder.appendInline(visit(ctx.string_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.LOWER() != null) { - builder.append(QueryTokens.token(ctx.LOWER())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.string_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + builder.append(visit(ctx.string_expression(0))); + + 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) { - builder.append(QueryTokens.token(ctx.UPPER())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.string_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); - } + builder.append(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.arithmetic_expression(0))); - return builder; - } + return QueryTokenStream.ofFunction(ctx.LEFT(), builder); + } else if (ctx.RIGHT() != null) { - @Override - public QueryTokenStream visitTrim_specification(EqlParser.Trim_specificationContext ctx) { + builder.appendInline(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.arithmetic_expression(0))); - if (ctx.LEADING() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.LEADING())); - } else if (ctx.TRAILING() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.TRAILING())); - } else { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.BOTH())); + 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 builder; } @Override @@ -1963,16 +844,13 @@ public QueryTokenStream visitArithmetic_cast_function(EqlParser.Arithmetic_cast_ QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.CAST())); - builder.append(TOKEN_OPEN_PAREN); builder.appendExpression(visit(ctx.string_expression())); if (ctx.AS() != null) { builder.append(QueryTokens.expression(ctx.AS())); } builder.append(QueryTokens.token(ctx.f)); - builder.append(TOKEN_CLOSE_PAREN); - return builder; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override @@ -1980,12 +858,12 @@ public QueryTokenStream visitType_cast_function(EqlParser.Type_cast_functionCont QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.CAST())); - builder.append(TOKEN_OPEN_PAREN); builder.appendExpression(visit(ctx.scalar_expression())); + if (ctx.AS() != null) { builder.append(QueryTokens.expression(ctx.AS())); } + builder.appendInline(visit(ctx.identification_variable())); if (!CollectionUtils.isEmpty(ctx.numeric_literal())) { @@ -1994,9 +872,8 @@ public QueryTokenStream visitType_cast_function(EqlParser.Type_cast_functionCont builder.appendInline(QueryTokenStream.concat(ctx.numeric_literal(), this::visit, TOKEN_COMMA)); builder.append(TOKEN_CLOSE_PAREN); } - builder.append(TOKEN_CLOSE_PAREN); - return builder; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override @@ -2004,16 +881,13 @@ public QueryTokenStream visitString_cast_function(EqlParser.String_cast_function QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.CAST())); - builder.append(TOKEN_OPEN_PAREN); builder.appendExpression(visit(ctx.scalar_expression())); if (ctx.AS() != null) { builder.append(QueryTokens.expression(ctx.AS())); } builder.append(QueryTokens.token(ctx.STRING())); - builder.append(TOKEN_CLOSE_PAREN); - return builder; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override @@ -2041,221 +915,39 @@ public QueryTokenStream visitFunction_invocation(EqlParser.Function_invocationCo @Override public QueryTokenStream visitExtract_datetime_field(EqlParser.Extract_datetime_fieldContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.EXTRACT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendExpression(visit(ctx.datetime_field())); - builder.append(QueryTokens.expression(ctx.FROM())); - builder.appendInline(visit(ctx.datetime_expression())); - builder.append(TOKEN_CLOSE_PAREN); + QueryRendererBuilder nested = QueryRenderer.builder(); - return builder; - } + nested.appendExpression(visit(ctx.datetime_field())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.datetime_expression())); - @Override - public QueryTokenStream visitDatetime_field(EqlParser.Datetime_fieldContext ctx) { - return visit(ctx.identification_variable()); + return QueryTokenStream.ofFunction(ctx.EXTRACT(), nested); } @Override public QueryTokenStream visitExtract_datetime_part(EqlParser.Extract_datetime_partContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.EXTRACT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendExpression(visit(ctx.datetime_part())); - builder.append(QueryTokens.expression(ctx.FROM())); - builder.appendInline(visit(ctx.datetime_expression())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitDatetime_part(EqlParser.Datetime_partContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream 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()); - } - } - - @Override - public QueryTokenStream 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 QueryTokenStream visitGeneral_case_expression(EqlParser.General_case_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.CASE())); - - ctx.when_clause().forEach(whenClauseContext -> { - builder.appendExpression(visit(whenClauseContext)); - }); - - builder.append(QueryTokens.expression(ctx.ELSE())); - builder.appendExpression(visit(ctx.scalar_expression())); - builder.append(QueryTokens.expression(ctx.END())); - - return builder; - } - - @Override - public QueryTokenStream visitWhen_clause(EqlParser.When_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.WHEN())); - builder.append(visit(ctx.conditional_expression())); - builder.append(QueryTokens.expression(ctx.THEN())); - builder.append(visit(ctx.scalar_expression())); - - return builder; - } - - @Override - public QueryTokenStream visitSimple_case_expression(EqlParser.Simple_case_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.CASE())); - builder.append(visit(ctx.case_operand())); - - ctx.simple_when_clause().forEach(simpleWhenClauseContext -> { - builder.append(visit(simpleWhenClauseContext)); - }); + QueryRendererBuilder nested = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.ELSE())); - builder.append(visit(ctx.scalar_expression())); - builder.append(QueryTokens.expression(ctx.END())); + nested.appendExpression(visit(ctx.datetime_part())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.datetime_expression())); - return builder; - } - - @Override - public QueryTokenStream 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()); - } - } - - @Override - public QueryTokenStream visitSimple_when_clause(EqlParser.Simple_when_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.WHEN())); - builder.appendExpression(visit(ctx.scalar_expression(0))); - builder.append(QueryTokens.expression(ctx.THEN())); - builder.appendExpression(visit(ctx.scalar_expression(1))); - - return builder; + return QueryTokenStream.ofFunction(ctx.EXTRACT(), nested); } @Override public QueryTokenStream visitCoalesce_expression(EqlParser.Coalesce_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.COALESCE())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; + return QueryTokenStream.ofFunction(ctx.COALESCE(), + QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); } @Override public QueryTokenStream visitNullif_expression(EqlParser.Nullif_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.NULLIF())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.scalar_expression(0))); - builder.append(TOKEN_COMMA); - builder.appendInline(visit(ctx.scalar_expression(1))); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitTrim_character(EqlParser.Trim_characterContext ctx) { - - if (ctx.CHARACTER() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.CHARACTER())); - } else if (ctx.character_valued_input_parameter() != null) { - return visit(ctx.character_valued_input_parameter()); - } else { - return QueryRenderer.builder(); - } - } - - @Override - public QueryTokenStream visitIdentification_variable(EqlParser.Identification_variableContext ctx) { - - if (ctx.IDENTIFICATION_VARIABLE() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.IDENTIFICATION_VARIABLE())); - } else if (ctx.f != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.f)); - } else { - return QueryRenderer.builder(); - } - } - - @Override - public QueryTokenStream visitConstructor_name(EqlParser.Constructor_nameContext ctx) { - return visit(ctx.entity_name()); - } - - @Override - public QueryTokenStream visitLiteral(EqlParser.LiteralContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.STRINGLITERAL() != null) { - builder.append(QueryTokens.expression(ctx.STRINGLITERAL())); - } else if (ctx.INTLITERAL() != null) { - builder.append(QueryTokens.expression(ctx.INTLITERAL())); - } else if (ctx.FLOATLITERAL() != null) { - builder.append(QueryTokens.expression(ctx.FLOATLITERAL())); - } else if (ctx.LONGLITERAL() != null) { - builder.append(QueryTokens.expression(ctx.LONGLITERAL())); - } else if (ctx.boolean_literal() != null) { - builder.append(visit(ctx.boolean_literal())); - } else if (ctx.entity_type_literal() != null) { - builder.append(visit(ctx.entity_type_literal())); - } - - return builder; + return QueryTokenStream.ofFunction(ctx.NULLIF(), + QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); } @Override @@ -2277,197 +969,24 @@ public QueryTokenStream visitInput_parameter(EqlParser.Input_parameterContext ct } @Override - public QueryTokenStream visitPattern_value(EqlParser.Pattern_valueContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.string_expression())); - - return builder; - } - - @Override - public QueryTokenStream visitDate_time_timestamp_literal(EqlParser.Date_time_timestamp_literalContext ctx) { - - if (ctx.STRINGLITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.STRINGLITERAL())); - } else if (ctx.DATELITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.DATELITERAL())); - } else if (ctx.TIMELITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.TIMELITERAL())); - } else if (ctx.TIMESTAMPLITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.TIMESTAMPLITERAL())); - } else { - return QueryRenderer.builder(); - } - } - - @Override - public QueryTokenStream visitEntity_type_literal(EqlParser.Entity_type_literalContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitEscape_character(EqlParser.Escape_characterContext ctx) { - - if (ctx.CHARACTER() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.CHARACTER())); - } else if (ctx.character_valued_input_parameter() != null) { - return visit(ctx.character_valued_input_parameter()); - } else if (ctx.string_literal() != null) { - return visit(ctx.string_literal()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitNumeric_literal(EqlParser.Numeric_literalContext ctx) { - - if (ctx.INTLITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INTLITERAL())); - } else if (ctx.FLOATLITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.FLOATLITERAL())); - } else if (ctx.LONGLITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.LONGLITERAL())); - } else { - return QueryRenderer.builder(); - } - } - - @Override - public QueryTokenStream visitBoolean_literal(EqlParser.Boolean_literalContext ctx) { - - if (ctx.TRUE() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.TRUE())); - } else if (ctx.FALSE() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.FALSE())); - } else { - return QueryRenderer.builder(); - } - } - - @Override - public QueryTokenStream visitEnum_literal(EqlParser.Enum_literalContext ctx) { - return visit(ctx.state_field_path_expression()); - } - - @Override - public QueryTokenStream visitString_literal(EqlParser.String_literalContext ctx) { - - if (ctx.CHARACTER() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.CHARACTER())); - } else if (ctx.STRINGLITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.STRINGLITERAL())); - } else { - return QueryRenderer.builder(); - } - } - - @Override - public QueryTokenStream visitSingle_valued_embeddable_object_field( - EqlParser.Single_valued_embeddable_object_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitSubtype(EqlParser.SubtypeContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitCollection_valued_field(EqlParser.Collection_valued_fieldContext ctx) { - - if (ctx.reserved_word() != null) { - return visit(ctx.reserved_word()); - } - - return visit(ctx.identification_variable()); + public QueryTokenStream visitEntity_name(EqlParser.Entity_nameContext ctx) { + return QueryTokenStream.concat(ctx.reserved_word(), this::visit, TOKEN_DOT); } @Override - public QueryTokenStream visitSingle_valued_object_field(EqlParser.Single_valued_object_fieldContext ctx) { - - if (ctx.reserved_word() != null) { - return visit(ctx.reserved_word()); - } - - return visit(ctx.identification_variable()); - } + public QueryTokenStream visitChildren(RuleNode node) { - @Override - public QueryTokenStream visitState_field(EqlParser.State_fieldContext ctx) { + int childCount = node.getChildCount(); - if (ctx.reserved_word() != null) { - return visit(ctx.reserved_word()); + if (childCount == 1 && node.getChild(0) instanceof RuleContext t) { + return visit(t); } - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitCollection_value_field(EqlParser.Collection_value_fieldContext ctx) { - - if (ctx.reserved_word() != null) { - return visit(ctx.reserved_word()); + if (childCount == 1 && node.getChild(0) instanceof TerminalNode t) { + return QueryTokens.token(t); } - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitEntity_name(EqlParser.Entity_nameContext ctx) { - return QueryTokenStream.concat(ctx.reserved_word(), this::visit, QueryRenderer::inline, TOKEN_DOT); - } - - @Override - public QueryTokenStream visitResult_variable(EqlParser.Result_variableContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitSuperquery_identification_variable( - EqlParser.Superquery_identification_variableContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitCollection_valued_input_parameter( - EqlParser.Collection_valued_input_parameterContext ctx) { - return visit(ctx.input_parameter()); - } - - @Override - public QueryTokenStream visitSingle_valued_input_parameter(EqlParser.Single_valued_input_parameterContext ctx) { - return visit(ctx.input_parameter()); - } - - @Override - public QueryTokenStream visitFunction_name(EqlParser.Function_nameContext ctx) { - return visit(ctx.string_literal()); - } - - @Override - public QueryTokenStream visitCharacter_valued_input_parameter( - EqlParser.Character_valued_input_parameterContext ctx) { - - if (ctx.CHARACTER() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.CHARACTER())); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } else { - return QueryRenderer.builder(); - } + return QueryTokenStream.concatExpressions(node, this::visit); } - @Override - public QueryTokenStream visitReserved_word(EqlParser.Reserved_wordContext ctx) { - if (ctx.IDENTIFICATION_VARIABLE() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.IDENTIFICATION_VARIABLE())); - } else if (ctx.f != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.f)); - } else { - return QueryRenderer.builder(); - } - } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java index e544024750..2cb03ae4df 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EqlSortedQueryTransformer.java @@ -19,12 +19,12 @@ 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.lang.Nullable; import org.springframework.util.Assert; -import org.springframework.util.ObjectUtils; /** * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed EQL query by applying @@ -54,7 +54,7 @@ class EqlSortedQueryTransformer extends EqlQueryRenderer { } @Override - public QueryRendererBuilder visitSelect_statement(EqlParser.Select_statementContext ctx) { + public QueryTokenStream visitSelectQuery(EqlParser.SelectQueryContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); @@ -73,62 +73,61 @@ public QueryRendererBuilder visitSelect_statement(EqlParser.Select_statementCont builder.appendExpression(visit(ctx.having_clause())); } - doVisitOrderBy(builder, ctx, ObjectUtils.isEmpty(ctx.setOperator()) ? this.sort : Sort.unsorted()); - - for (int i = 0; i < ctx.setOperator().size(); i++) { - - builder.appendExpression(visit(ctx.setOperator(i))); - builder.appendExpression(visit(ctx.select_statement(i))); + if (ctx.set_fuction() != null) { + builder.appendExpression(visit(ctx.set_fuction())); + } else { + doVisitOrderBy(builder, ctx.orderby_clause()); } return builder; } @Override - public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { - - if (dtoDelegate == null) { - return super.visitSelect_clause(ctx); - } + public QueryTokenStream visitFromQuery(EqlParser.FromQueryContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.SELECT())); + builder.appendExpression(visit(ctx.from_clause())); - if (ctx.DISTINCT() != null) { - builder.append(QueryTokens.expression(ctx.DISTINCT())); + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); } - QueryTokenStream tokenStream = QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA); - - return builder.append(dtoDelegate.transformSelectionList(tokenStream)); - } + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } - private void doVisitOrderBy(QueryRendererBuilder builder, EqlParser.Select_statementContext ctx, Sort sort) { + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } - if (ctx.orderby_clause() != null) { - QueryTokenStream existingOrder = visit(ctx.orderby_clause()); - if (sort.isSorted()) { - builder.appendInline(existingOrder); - } else { - builder.append(existingOrder); - } + if (ctx.set_fuction() != null) { + builder.appendExpression(visit(ctx.set_fuction())); + } else { + doVisitOrderBy(builder, ctx.orderby_clause()); } - if (sort.isSorted()) { + return builder; + } - List sortBy = transformerSupport.orderBy(primaryFromAlias, sort); + @Override + public QueryTokenStream visitSelect_clause(EqlParser.Select_clauseContext ctx) { - if (ctx.orderby_clause() != null) { + if (dtoDelegate == null) { + return super.visitSelect_clause(ctx); + } - QueryRendererBuilder extension = QueryRenderer.builder().append(TOKEN_COMMA).append(sortBy); + QueryRendererBuilder builder = prepareSelectClause(ctx); - builder.appendInline(extension); - } else { - builder.append(TOKEN_ORDER_BY); - builder.append(sortBy); - } + 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 @@ -137,22 +136,61 @@ public QueryTokenStream visitSelect_item(EqlParser.Select_itemContext ctx) { QueryTokenStream tokens = super.visitSelect_item(ctx); if (ctx.result_variable() != null && !tokens.isEmpty()) { - transformerSupport.registerAlias(tokens.getLast()); + transformerSupport.registerAlias(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 (!tokens.isEmpty()) { - transformerSupport.registerAlias(tokens.getLast()); + 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 d73680ff62..d6ef5c321b 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EscapeCharacter.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/EscapeCharacter.java @@ -19,7 +19,8 @@ import java.util.List; import java.util.stream.Stream; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; +import org.springframework.lang.Contract; /** * A value type encapsulating an escape character for LIKE queries and the actually usage of it in escaping @@ -49,8 +50,8 @@ public static EscapeCharacter of(char escapeCharacter) { * @param value may be {@literal null}. * @return */ - @Nullable - public String escape(@Nullable String value) { + @Contract("null -> null") + public @Nullable String escape(@Nullable String value) { return value == null // ? null // diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/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 37b06f0744..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 @@ -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 index 755dade914..e1ea9173d1 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateQueryInformation.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HibernateQueryInformation.java @@ -17,25 +17,35 @@ import java.util.List; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * Hibernate-specific query details capturing common table expression details. * * @author Mark Paluch + * @author Oscar Fanchin * @since 3.5 */ class HibernateQueryInformation extends QueryInformation { private final boolean hasCte; + + private final boolean hasFromFunction; + public HibernateQueryInformation(@Nullable String alias, List projection, - boolean hasConstructorExpression, boolean hasCte) { + boolean hasConstructorExpression, boolean hasCte,boolean hasFromFunction) { super(alias, projection, hasConstructorExpression); 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 index b6a90c5599..6f9c3bb878 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlCountQueryTransformer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlCountQueryTransformer.java @@ -17,10 +17,12 @@ 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.lang.Nullable; +import org.springframework.util.StringUtils; /** * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed HQL query into a @@ -29,6 +31,7 @@ * @author Greg Turnquist * @author Christoph Strobl * @author Mark Paluch + * @author Oscar Fanchin * @since 3.1 */ @SuppressWarnings("ConstantValue") @@ -37,11 +40,13 @@ 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 @@ -103,13 +108,12 @@ public QueryRendererBuilder visitFromQuery(HqlParser.FromQueryContext ctx) { if (ctx.fromClause() != null) { builder.appendExpression(visit(ctx.fromClause())); - if(primaryFromAlias == null) { + if (primaryFromAlias == null) { builder.append(TOKEN_AS); builder.append(TOKEN_DOUBLE_UNDERSCORE); } } - if (ctx.whereClause() != null) { builder.appendExpression(visit(ctx.whereClause())); } @@ -129,45 +133,12 @@ public QueryRendererBuilder visitFromQuery(HqlParser.FromQueryContext ctx) { return builder; } - @Override - public QueryRendererBuilder visitFromRoot(HqlParser.FromRootContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.entityName() != null) { - - builder.appendExpression(visit(ctx.entityName())); - - if (ctx.variable() != null) { - builder.appendExpression(visit(ctx.variable())); - } - } else if (ctx.subquery() != null) { - - if (ctx.LATERAL() != null) { - builder.append(QueryTokens.expression(ctx.LATERAL())); - } - - QueryRendererBuilder nested = QueryRenderer.builder(); - - nested.append(TOKEN_OPEN_PAREN); - nested.appendInline(visit(ctx.subquery())); - nested.append(TOKEN_CLOSE_PAREN); - - builder.appendExpression(nested); - - if (ctx.variable() != null) { - builder.appendExpression(visit(ctx.variable())); - } - } - - 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())); @@ -194,21 +165,17 @@ public QueryTokenStream visitSelectClause(HqlParser.SelectClauseContext ctx) { boolean usesDistinct = ctx.DISTINCT() != null; QueryRendererBuilder nested = QueryRenderer.builder(); if (countProjection == null) { - QueryTokenStream selection = visit(ctx.selectionList()); if (usesDistinct) { - nested.append(QueryTokens.expression(ctx.DISTINCT())); - nested.append(getDistinctCountSelection(selection)); + 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) { + if (containsCTE || containsFromFunction) { nested.append(QueryTokens.token("*")); } else { - if (selection.size() == 1) { - nested.append(selection); - } else if (primaryFromAlias != null) { + if (StringUtils.hasText(primaryFromAlias)) { nested.append(QueryTokens.token(primaryFromAlias)); } else { nested.append(QueryTokens.token("*")); @@ -216,10 +183,10 @@ public QueryTokenStream visitSelectClause(HqlParser.SelectClauseContext ctx) { } } } else { - builder.append(QueryTokens.token(countProjection)); if (usesDistinct) { nested.append(QueryTokens.expression(ctx.DISTINCT())); } + nested.append(QueryTokens.token(countProjection)); } builder.appendInline(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 index 5ccc7b3556..c32bf27d3f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryIntrospector.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlQueryIntrospector.java @@ -21,13 +21,15 @@ import java.util.Collections; import java.util.List; +import org.jspecify.annotations.Nullable; + import org.springframework.data.jpa.repository.query.HqlParser.VariableContext; -import org.springframework.lang.Nullable; /** * {@link ParsedQueryIntrospector} for HQL queries. * * @author Mark Paluch + * @author Oscar Fanchin */ @SuppressWarnings({ "UnreachableCode", "ConstantValue" }) class HqlQueryIntrospector extends HqlBaseVisitor implements ParsedQueryIntrospector { @@ -39,19 +41,20 @@ class HqlQueryIntrospector extends HqlBaseVisitor implements ParsedQueryIn private boolean projectionProcessed; private boolean hasConstructorExpression = false; private boolean hasCte = false; + private boolean hasFromFunction = false; @Override public HibernateQueryInformation getParsedQueryInformation() { return new HibernateQueryInformation(primaryFromAlias, projection == null ? Collections.emptyList() : projection, - hasConstructorExpression, hasCte); + hasConstructorExpression, hasCte, hasFromFunction); } @Override public Void visitSelectClause(HqlParser.SelectClauseContext ctx) { - if (!projectionProcessed) { - projection = captureSelectItems(ctx.selectionList().selection(), renderer); - projectionProcessed = true; + if (!this.projectionProcessed) { + this.projection = captureSelectItems(ctx.selectionList().selection(), renderer); + this.projectionProcessed = true; } return super.visitSelectClause(ctx); @@ -64,13 +67,37 @@ public Void visitCte(HqlParser.CteContext ctx) { } @Override - public Void visitFromRoot(HqlParser.FromRootContext ctx) { + public Void visitRootEntity(HqlParser.RootEntityContext ctx) { + + if (this.primaryFromAlias == null && ctx.variable() != null && !HqlQueryRenderer.isSubquery(ctx) + && !HqlQueryRenderer.isSetQuery(ctx)) { + this.primaryFromAlias = capturePrimaryAlias(ctx.variable()); + } + + return super.visitRootEntity(ctx); + } + + @Override + public Void visitRootSubquery(HqlParser.RootSubqueryContext ctx) { + + if (this.primaryFromAlias == null && ctx.variable() != null && !HqlQueryRenderer.isSubquery(ctx) + && !HqlQueryRenderer.isSetQuery(ctx)) { + this.primaryFromAlias = capturePrimaryAlias(ctx.variable()); + } + + return super.visitRootSubquery(ctx); + } + + @Override + public Void visitRootFunction(HqlParser.RootFunctionContext ctx) { - if (primaryFromAlias == null && ctx.variable() != null && !HqlQueryRenderer.isSubquery(ctx)) { - primaryFromAlias = capturePrimaryAlias(ctx.variable()); + if (this.primaryFromAlias == null && ctx.variable() != null && !HqlQueryRenderer.isSubquery(ctx) + && !HqlQueryRenderer.isSetQuery(ctx)) { + this.primaryFromAlias = capturePrimaryAlias(ctx.variable()); + this.hasFromFunction = true; } - return super.visitFromRoot(ctx); + return super.visitRootFunction(ctx); } @Override 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 6b1bf850e7..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 @@ -21,93 +21,85 @@ 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.ObjectUtils; +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", "UnreachableCode" }) class HqlQueryRenderer extends HqlBaseVisitor { /** - * Is this select clause a {@literal subquery}? + * Is this AST tree a {@literal subquery}? * - * @return boolean + * @return {@literal true} is the query is a subquery; {@literal false} otherwise. */ static boolean isSubquery(ParserRuleContext ctx) { - if (ctx instanceof HqlParser.SubqueryContext || ctx instanceof HqlParser.CteContext) { - return true; - } else if (ctx instanceof HqlParser.SelectStatementContext) { - return false; - } else if (ctx instanceof HqlParser.InsertStatementContext) { - return false; - } else if (ctx instanceof HqlParser.DeleteStatementContext) { - return false; - } else if (ctx instanceof HqlParser.UpdateStatementContext) { - return false; - } else { - return ctx.getParent() != null && isSubquery(ctx.getParent()); - } - } + while (ctx != null) { - @Override - public QueryTokenStream visitStart(HqlParser.StartContext ctx) { - return visit(ctx.ql_statement()); - } + if (ctx instanceof HqlParser.SubqueryContext || ctx instanceof HqlParser.CteContext) { + return true; + } - @Override - public QueryTokenStream visitQl_statement(HqlParser.Ql_statementContext ctx) { + if (ctx instanceof HqlParser.SelectStatementContext || + ctx instanceof HqlParser.InsertStatementContext || + ctx instanceof HqlParser.DeleteStatementContext || + ctx instanceof HqlParser.UpdateStatementContext) { + return false; + } - 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 QueryTokenStream.empty(); + ctx = ctx.getParent(); } - } - @Override - public QueryTokenStream visitSelectStatement(HqlParser.SelectStatementContext ctx) { - return visit(ctx.queryExpression()); + return false; } - @Override - public QueryTokenStream visitQueryExpression(HqlParser.QueryExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.withClause() != null) { - builder.appendExpression(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) { - builder.append(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; + } + } - builder.append(visit(ctx.setOperator(i - 1))); - builder.append(visit(ctx.orderedQuery(i))); + ctx = ctx.getParent(); } - return builder; + return false; + } + + @Override + public QueryTokenStream visitStart(HqlParser.StartContext ctx) { + return visit(ctx.ql_statement()); } @Override public QueryTokenStream visitWithClause(HqlParser.WithClauseContext ctx) { - QueryRendererBuilder builder = QueryRendererBuilder.from(TOKEN_WITH); + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(QueryTokens.expression(ctx.WITH())); builder.append(QueryTokenStream.concatExpressions(ctx.cte(), this::visit, TOKEN_COMMA)); return builder; @@ -145,25 +137,8 @@ public QueryTokenStream visitCte(HqlParser.CteContext ctx) { } @Override - public QueryTokenStream visitSearchClause(HqlParser.SearchClauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.SEARCH())); - - if (ctx.BREADTH() != null) { - builder.append(QueryTokens.expression(ctx.BREADTH())); - } else if (ctx.DEPTH() != null) { - builder.append(QueryTokens.expression(ctx.DEPTH())); - } - - builder.append(QueryTokens.expression(ctx.FIRST())); - builder.append(QueryTokens.expression(ctx.BY())); - builder.append(visit(ctx.searchSpecifications())); - builder.append(QueryTokens.expression(ctx.SET())); - builder.append(visit(ctx.identifier())); - - return builder; + public QueryTokenStream visitCteAttributes(HqlParser.CteAttributesContext ctx) { + return QueryTokenStream.concat(ctx.identifier(), this::visit, TOKEN_COMMA); } @Override @@ -171,56 +146,6 @@ public QueryTokenStream visitSearchSpecifications(HqlParser.SearchSpecifications return QueryTokenStream.concat(ctx.searchSpecification(), this::visit, TOKEN_COMMA); } - @Override - public QueryTokenStream visitSearchSpecification(HqlParser.SearchSpecificationContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.identifier())); - - if (ctx.sortDirection() != null) { - builder.append(visit(ctx.sortDirection())); - } - - if (ctx.nullsPrecedence() != null) { - builder.append(visit(ctx.nullsPrecedence())); - } - - return builder; - } - - @Override - public QueryTokenStream visitCycleClause(HqlParser.CycleClauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.CYCLE().getText())); - builder.append(visit(ctx.cteAttributes())); - builder.append(QueryTokens.expression(ctx.SET().getText())); - builder.append(visit(ctx.identifier(0))); - - if (ctx.TO() != null) { - - builder.append(QueryTokens.expression(ctx.TO().getText())); - builder.append(visit(ctx.literal(0))); - builder.append(QueryTokens.expression(ctx.DEFAULT().getText())); - builder.append(visit(ctx.literal(1))); - } - - if (ctx.USING() != null) { - - builder.append(QueryTokens.expression(ctx.USING().getText())); - builder.append(visit(ctx.identifier(1))); - } - - return builder; - } - - @Override - public QueryTokenStream visitCteAttributes(HqlParser.CteAttributesContext ctx) { - return QueryTokenStream.concat(ctx.identifier(), this::visit, TOKEN_COMMA); - } - @Override public QueryTokenStream visitOrderedQuery(HqlParser.OrderedQueryContext ctx) { @@ -254,65 +179,6 @@ public QueryTokenStream visitOrderedQuery(HqlParser.OrderedQueryContext ctx) { return builder; } - @Override - public QueryTokenStream visitSelectQuery(HqlParser.SelectQueryContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.selectClause() != null) { - builder.appendExpression(visit(ctx.selectClause())); - } - - if (ctx.fromClause() != null) { - builder.appendExpression(visit(ctx.fromClause())); - } - - 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())); - } - - return builder; - } - - @Override - public QueryTokenStream visitFromQuery(HqlParser.FromQueryContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.fromClause())); - - if (ctx.whereClause() != null) { - builder.append(visit(ctx.whereClause())); - } - - if (ctx.groupByClause() != null) { - builder.append(visit(ctx.groupByClause())); - } - - if (ctx.havingClause() != null) { - builder.append(visit(ctx.havingClause())); - } - - if (ctx.selectClause() != null) { - builder.append(visit(ctx.selectClause())); - } - - return builder; - } - - @Override - public QueryTokenStream visitQueryOrder(HqlParser.QueryOrderContext ctx) { - return visit(ctx.orderByClause()); - } - @Override public QueryTokenStream visitFromClause(HqlParser.FromClauseContext ctx) { @@ -329,57 +195,42 @@ public QueryTokenStream visitEntityWithJoins(HqlParser.EntityWithJoinsContext ct QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.fromRoot())); - builder.appendInline(QueryTokenStream.concat(ctx.joinSpecifier(), this::visit, TOKEN_SPACE)); + builder.appendInline(visit(ctx.fromRoot())); + builder.appendInline(QueryTokenStream.concat(ctx.joinSpecifier(), this::visit, EMPTY_TOKEN)); return builder; } @Override - public QueryTokenStream visitJoinSpecifier(HqlParser.JoinSpecifierContext 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 QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitFromRoot(HqlParser.FromRootContext ctx) { + public QueryTokenStream visitRootSubquery(HqlParser.RootSubqueryContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.entityName() != null) { - - builder.appendExpression(visit(ctx.entityName())); + if (ctx.LATERAL() != null) { + builder.append(QueryTokens.expression(ctx.LATERAL())); + } - if (ctx.variable() != null) { - builder.appendExpression(visit(ctx.variable())); - } + builder.appendExpression(QueryTokenStream.group(visit(ctx.subquery()))); - } else if (ctx.subquery() != null) { + if (ctx.variable() != null) { + builder.appendExpression(visit(ctx.variable())); + } - if (ctx.LATERAL() != null) { - builder.append(QueryTokens.expression(ctx.LATERAL())); - } + return builder; + } - QueryRendererBuilder nested = QueryRenderer.builder(); + @Override + public QueryTokenStream visitSimpleSetReturningFunction(HqlParser.SimpleSetReturningFunctionContext ctx) { - nested.append(TOKEN_OPEN_PAREN); - nested.appendInline(visit(ctx.subquery())); - nested.append(TOKEN_CLOSE_PAREN); + QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(nested); + builder.append(visit(ctx.identifier())); - if (ctx.variable() != null) { - builder.appendExpression(visit(ctx.variable())); - } + builder.append(TOKEN_OPEN_PAREN); + if (ctx.genericFunctionArguments() != null) { + builder.append(visit(ctx.genericFunctionArguments())); } + builder.append(TOKEN_CLOSE_PAREN); return builder; } @@ -389,6 +240,7 @@ public QueryTokenStream visitJoin(HqlParser.JoinContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(TOKEN_SPACE); builder.append(visit(ctx.joinType())); builder.append(QueryTokens.expression(ctx.JOIN())); @@ -405,22 +257,6 @@ public QueryTokenStream visitJoin(HqlParser.JoinContext ctx) { return builder; } - @Override - public QueryTokenStream visitJoinPath(HqlParser.JoinPathContext ctx) { - - HqlParser.VariableContext variable = ctx.variable(); - - if (variable == null) { - return visit(ctx.path()); - } - - QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.path())); - builder.appendExpression(visit(variable)); - - return builder; - } - @Override public QueryTokenStream visitJoinSubquery(HqlParser.JoinSubqueryContext ctx) { @@ -430,9 +266,7 @@ public QueryTokenStream visitJoinSubquery(HqlParser.JoinSubqueryContext ctx) { builder.append(QueryTokens.expression(ctx.LATERAL())); } - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); if (ctx.variable() != null) { builder.appendExpression(visit(ctx.variable())); @@ -441,43 +275,6 @@ public QueryTokenStream visitJoinSubquery(HqlParser.JoinSubqueryContext ctx) { return builder; } - @Override - public QueryTokenStream visitUpdateStatement(HqlParser.UpdateStatementContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.UPDATE())); - - if (ctx.VERSIONED() != null) { - builder.append(QueryTokens.expression(ctx.VERSIONED())); - } - - builder.appendExpression(visit(ctx.targetEntity())); - builder.appendExpression(visit(ctx.setClause())); - - if (ctx.whereClause() != null) { - builder.appendExpression(visit(ctx.whereClause())); - } - - return builder; - } - - @Override - public QueryTokenStream visitTargetEntity(HqlParser.TargetEntityContext ctx) { - - HqlParser.VariableContext variable = ctx.variable(); - - if (variable == null) { - return visit(ctx.entityName()); - } - - QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.entityName())); - builder.appendExpression(visit(variable)); - - return builder; - } - @Override public QueryTokenStream visitSetClause(HqlParser.SetClauseContext ctx) { @@ -500,3302 +297,1720 @@ public QueryTokenStream visitAssignment(HqlParser.AssignmentContext ctx) { } @Override - public QueryTokenStream visitDeleteStatement(HqlParser.DeleteStatementContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.DELETE())); - - if (ctx.FROM() != null) { - builder.append(QueryTokens.expression(ctx.FROM())); - } - - builder.append(visit(ctx.targetEntity())); - - if (ctx.whereClause() != null) { - builder.append(visit(ctx.whereClause())); - } - - return builder; + public QueryTokenStream visitTargetFields(HqlParser.TargetFieldsContext ctx) { + return QueryTokenStream.group(QueryTokenStream.concat(ctx.simplePath(), this::visit, TOKEN_COMMA)); } @Override - public QueryTokenStream visitInsertStatement(HqlParser.InsertStatementContext ctx) { + public QueryTokenStream visitValuesList(HqlParser.ValuesListContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.INSERT())); + builder.append(QueryTokens.expression(ctx.VALUES())); + builder.append(QueryTokenStream.concat(ctx.values(), this::visit, TOKEN_COMMA)); - if (ctx.INTO() != null) { - builder.append(QueryTokens.expression(ctx.INTO())); - } + return builder; + } - builder.appendExpression(visit(ctx.targetEntity())); - builder.appendExpression(visit(ctx.targetFields())); + @Override + public QueryTokenStream visitValues(HqlParser.ValuesContext ctx) { + return QueryTokenStream.group(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); + } - if (ctx.queryExpression() != null) { - builder.appendExpression(visit(ctx.queryExpression())); - } else if (ctx.valuesList() != null) { - builder.appendExpression(visit(ctx.valuesList())); - } + @Override + public QueryTokenStream visitConflictTarget(HqlParser.ConflictTargetContext ctx) { - if (ctx.conflictClause() != null) { - builder.appendExpression(visit(ctx.conflictClause())); + if (ctx.identifier() != null) { + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } - return builder; + return QueryTokenStream.group(QueryTokenStream.concat(ctx.simplePath(), this::visit, TOKEN_COMMA)); } @Override - public QueryTokenStream visitTargetFields(HqlParser.TargetFieldsContext ctx) { + public QueryTokenStream visitInstantiation(HqlParser.InstantiationContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(TOKEN_OPEN_PAREN); - builder.append(QueryTokenStream.concat(ctx.simplePath(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.expression(ctx.NEW())); + builder.append(visit(ctx.instantiationTarget())); + builder.append(QueryTokenStream.group(visit(ctx.instantiationArguments()))); return builder; } + public QueryTokenStream visitSelectionList(HqlParser.SelectionListContext ctx) { + return QueryTokenStream.concat(ctx.selection(), this::visit, TOKEN_COMMA); + } @Override - public QueryTokenStream visitValuesList(HqlParser.ValuesListContext ctx) { + public QueryTokenStream visitMapEntrySelection(HqlParser.MapEntrySelectionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.VALUES())); - builder.append(QueryTokenStream.concat(ctx.values(), this::visit, TOKEN_COMMA)); + builder.append(QueryTokens.expression(ctx.ENTRY())); + builder.append(QueryTokenStream.group(visit(ctx.path()))); return builder; } @Override - public QueryTokenStream visitValues(HqlParser.ValuesContext ctx) { + public QueryTokenStream visitJpaSelectObjectSyntax(HqlParser.JpaSelectObjectSyntaxContext ctx) { + return QueryTokenStream.ofFunction(ctx.OBJECT(), visit(ctx.identifier())); + } + + @Override + public QueryTokenStream visitWhereClause(HqlParser.WhereClauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(TOKEN_OPEN_PAREN); - builder.append(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.expression(ctx.WHERE())); + builder.append(QueryTokenStream.concatExpressions(ctx.predicate(), this::visit, TOKEN_COMMA)); return builder; } @Override - public QueryTokenStream visitConflictClause(HqlParser.ConflictClauseContext ctx) { + public QueryTokenStream visitJpaCollectionJoin(HqlParser.JpaCollectionJoinContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.ON())); - builder.append(QueryTokens.expression(ctx.CONFLICT())); + builder.append(TOKEN_COMMA); + builder.append(QueryTokens.token(ctx.IN())); + builder.append(QueryTokenStream.group(visit(ctx.path()))); - if (ctx.conflictTarget() != null) { - builder.appendExpression(visit(ctx.conflictTarget())); + if (ctx.variable() != null) { + builder.appendExpression(visit(ctx.variable())); } - builder.append(QueryTokens.expression(ctx.DO())); - builder.appendExpression(visit(ctx.conflictAction())); - return builder; } @Override - public QueryTokenStream visitConflictTarget(HqlParser.ConflictTargetContext ctx) { + public QueryTokenStream visitGroupByClause(HqlParser.GroupByClauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.identifier() != null) { + builder.append(QueryTokens.expression(ctx.GROUP())); + builder.append(QueryTokens.expression(ctx.BY())); + builder.append(QueryTokenStream.concat(ctx.groupedItem(), this::visit, TOKEN_COMMA)); - builder.append(QueryTokens.expression(ctx.ON())); - builder.append(QueryTokens.expression(ctx.CONSTRAINT())); - builder.appendExpression(visit(ctx.identifier())); - } + return builder; + } - if (!ObjectUtils.isEmpty(ctx.simplePath())) { + @Override + public QueryTokenStream visitOrderByClause(HqlParser.OrderByClauseContext ctx) { - builder.append(TOKEN_OPEN_PAREN); - builder.append(QueryTokenStream.concat(ctx.simplePath(), this::visit, TOKEN_COMMA)); + QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(TOKEN_CLOSE_PAREN); - } + 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 QueryTokenStream visitConflictAction(HqlParser.ConflictActionContext ctx) { + public QueryTokenStream visitHavingClause(HqlParser.HavingClauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.NOTHING() != null) { - builder.append(QueryTokens.expression(ctx.NOTHING())); - } else { - builder.append(QueryTokens.expression(ctx.UPDATE())); - builder.appendExpression(visit(ctx.setClause())); - - if (ctx.whereClause() != null) { - builder.appendExpression(visit(ctx.whereClause())); - } - } + builder.append(QueryTokens.expression(ctx.HAVING())); + builder.appendExpression(QueryTokenStream.concat(ctx.predicate(), this::visit, TOKEN_COMMA)); return builder; } @Override - public QueryTokenStream visitInstantiation(HqlParser.InstantiationContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + public QueryTokenStream visitLocalDateTimeLiteral(HqlParser.LocalDateTimeLiteralContext ctx) { - builder.append(QueryTokens.expression(ctx.NEW())); - builder.append(visit(ctx.instantiationTarget())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.instantiationArguments())); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.DATETIME() == null) { + return QueryTokenStream.group(visit(ctx.localDateTime())); + } - return builder; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitGroupedItem(HqlParser.GroupedItemContext ctx) { + public QueryTokenStream visitZonedDateTimeLiteral(HqlParser.ZonedDateTimeLiteralContext ctx) { - if (ctx.identifier() != null) { - return visit(ctx.identifier()); - } else if (ctx.INTEGER_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.INTEGER_LITERAL())); - } else if (ctx.expression() != null) { - return visit(ctx.expression()); - } else { - return QueryTokenStream.empty(); + if (ctx.DATETIME() == null) { + return QueryTokenStream.group(visit(ctx.zonedDateTime())); } + + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitSortedItem(HqlParser.SortedItemContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.sortExpression())); - - if (ctx.sortDirection() != null) { - builder.append(visit(ctx.sortDirection())); - } + public QueryTokenStream visitOffsetDateTimeLiteral(HqlParser.OffsetDateTimeLiteralContext ctx) { - if (ctx.nullsPrecedence() != null) { - builder.appendExpression(visit(ctx.nullsPrecedence())); + if (ctx.DATETIME() == null) { + return QueryTokenStream.group(visit(ctx.offsetDateTime())); } - return builder; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitSortExpression(HqlParser.SortExpressionContext ctx) { + public QueryTokenStream visitDateLiteral(HqlParser.DateLiteralContext ctx) { - if (ctx.identifier() != null) { - return visit(ctx.identifier()); - } else if (ctx.INTEGER_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.INTEGER_LITERAL())); - } else if (ctx.expression() != null) { - return visit(ctx.expression()); - } else { - return QueryTokenStream.empty(); + if (ctx.DATE() == null) { + return QueryTokenStream.group(visit(ctx.date())); } + + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitSortDirection(HqlParser.SortDirectionContext ctx) { + public QueryTokenStream visitTimeLiteral(HqlParser.TimeLiteralContext ctx) { - if (ctx.ASC() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.ASC())); - } else if (ctx.DESC() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.DESC())); - } else { - return QueryTokenStream.empty(); + if (ctx.TIME() == null) { + return QueryTokenStream.group(visit(ctx.time())); } + + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitNullsPrecedence(HqlParser.NullsPrecedenceContext ctx) { + public QueryTokenStream visitOffsetDateTime(HqlParser.OffsetDateTimeContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.NULLS())); - - if (ctx.FIRST() != null) { - builder.append(QueryTokens.expression(ctx.FIRST())); - } else if (ctx.LAST() != null) { - builder.append(QueryTokens.expression(ctx.LAST())); - } + builder.appendExpression(visit(ctx.date())); + builder.appendInline(visit(ctx.time())); + builder.appendInline(visit(ctx.offset())); return builder; } @Override - public QueryTokenStream visitLimitClause(HqlParser.LimitClauseContext ctx) { + public QueryTokenStream visitOffsetDateTimeWithMinutes(HqlParser.OffsetDateTimeWithMinutesContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.LIMIT())); - builder.append(visit(ctx.parameterOrIntegerLiteral())); + builder.appendExpression(visit(ctx.date())); + builder.appendInline(visit(ctx.time())); + builder.appendInline(visit(ctx.offsetWithMinutes())); return builder; } @Override - public QueryTokenStream visitOffsetClause(HqlParser.OffsetClauseContext ctx) { + public QueryTokenStream visitJdbcTimestampLiteral(HqlParser.JdbcTimestampLiteralContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.OFFSET())); - builder.append(visit(ctx.parameterOrIntegerLiteral())); - - if (ctx.ROW() != null) { - builder.append(QueryTokens.expression(ctx.ROW())); - } else if (ctx.ROWS() != null) { - builder.append(QueryTokens.expression(ctx.ROWS())); - } + 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 builder; } @Override - public QueryTokenStream visitFetchClause(HqlParser.FetchClauseContext ctx) { + public QueryTokenStream visitJdbcDateLiteral(HqlParser.JdbcDateLiteralContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.FETCH())); - - if (ctx.FIRST() != null) { - builder.append(QueryTokens.expression(ctx.FIRST())); - } else if (ctx.NEXT() != null) { - builder.append(QueryTokens.expression(ctx.NEXT())); - } - - if (ctx.parameterOrIntegerLiteral() != null) { - builder.append(visit(ctx.parameterOrIntegerLiteral())); - } else if (ctx.parameterOrNumberLiteral() != null) { - builder.append(visit(ctx.parameterOrNumberLiteral())); - } - - if (ctx.ROW() != null) { - builder.append(QueryTokens.expression(ctx.ROW())); - } else if (ctx.ROWS() != null) { - builder.append(QueryTokens.expression(ctx.ROWS())); - } - - if (ctx.ONLY() != null) { - builder.append(QueryTokens.expression(ctx.ONLY())); - } else if (ctx.WITH() != null) { - - builder.append(QueryTokens.expression(ctx.WITH())); - builder.append(QueryTokens.expression(ctx.TIES())); - } + 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); return builder; } @Override - public QueryTokenStream visitSubquery(HqlParser.SubqueryContext ctx) { - return visit(ctx.queryExpression()); - } - - @Override - public QueryTokenStream visitSelectClause(HqlParser.SelectClauseContext ctx) { + public QueryTokenStream visitJdbcTimeLiteral(HqlParser.JdbcTimeLiteralContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.SELECT())); - - if (ctx.DISTINCT() != null) { - builder.append(QueryTokens.expression(ctx.DISTINCT())); - } - - builder.appendExpression(visit(ctx.selectionList())); + 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); return builder; } @Override - public QueryTokenStream visitSelectionList(HqlParser.SelectionListContext ctx) { - return QueryTokenStream.concat(ctx.selection(), this::visit, TOKEN_COMMA); - } - - @Override - public QueryTokenStream visitSelection(HqlParser.SelectionContext ctx) { + public QueryTokenStream visitArrayLiteral(HqlParser.ArrayLiteralContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.selectExpression())); - - if (ctx.variable() != null) { - builder.appendExpression(visit(ctx.variable())); - } + builder.append(TOKEN_OPEN_SQUARE_BRACKET); + builder.append(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); + builder.append(TOKEN_CLOSE_SQUARE_BRACKET); return builder; } @Override - public QueryTokenStream visitSelectExpression(HqlParser.SelectExpressionContext 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 QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitMapEntrySelection(HqlParser.MapEntrySelectionContext ctx) { + public QueryTokenStream visitGeneralizedLiteral(HqlParser.GeneralizedLiteralContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.ENTRY())); builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.path())); + builder.append(visit(ctx.generalizedLiteralType())); + builder.append(TOKEN_COLON); + builder.append(visit(ctx.generalizedLiteralText())); builder.append(TOKEN_CLOSE_PAREN); return builder; } @Override - public QueryTokenStream visitJpaSelectObjectSyntax(HqlParser.JpaSelectObjectSyntaxContext ctx) { + public QueryTokenStream visitDate(HqlParser.DateContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.OBJECT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.identifier())); - builder.append(TOKEN_CLOSE_PAREN); + builder.append(visit(ctx.year())); + builder.append(TOKEN_DASH); + builder.append(visit(ctx.month())); + builder.append(TOKEN_DASH); + builder.append(visit(ctx.day())); return builder; } @Override - public QueryTokenStream visitWhereClause(HqlParser.WhereClauseContext ctx) { + public QueryTokenStream visitTime(HqlParser.TimeContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(visit(ctx.hour())); + builder.append(TOKEN_COLON); + builder.append(visit(ctx.minute())); - builder.append(QueryTokens.expression(ctx.WHERE())); - builder.append(QueryTokenStream.concatExpressions(ctx.predicate(), this::visit, TOKEN_COMMA)); + if (ctx.second() != null) { + builder.append(TOKEN_COLON); + builder.append(visit(ctx.second())); + } return builder; } @Override - public QueryTokenStream visitJoinType(HqlParser.JoinTypeContext ctx) { + public QueryTokenStream visitOffset(HqlParser.OffsetContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.INNER() != null) { - builder.append(QueryTokens.expression(ctx.INNER())); - } - if (ctx.LEFT() != null) { - builder.append(QueryTokens.expression(ctx.LEFT())); - } - if (ctx.RIGHT() != null) { - builder.append(QueryTokens.expression(ctx.RIGHT())); - } - if (ctx.FULL() != null) { - builder.append(QueryTokens.expression(ctx.FULL())); - } - if (ctx.OUTER() != null) { - builder.append(QueryTokens.expression(ctx.OUTER())); + if (ctx.MINUS() != null) { + builder.append(QueryTokens.token(ctx.MINUS())); + } else if (ctx.PLUS() != null) { + builder.append(QueryTokens.token(ctx.PLUS())); } - if (ctx.CROSS() != null) { - builder.append(QueryTokens.expression(ctx.CROSS())); + builder.append(visit(ctx.hour())); + + if (ctx.minute() != null) { + builder.append(TOKEN_COLON); + builder.append(visit(ctx.minute())); } return builder; } @Override - public QueryTokenStream visitCrossJoin(HqlParser.CrossJoinContext ctx) { + public QueryTokenStream visitOffsetWithMinutes(HqlParser.OffsetWithMinutesContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.CROSS())); - builder.append(QueryTokens.expression(ctx.JOIN())); - builder.appendExpression(visit(ctx.entityName())); - - if (ctx.variable() != null) { - builder.appendExpression(visit(ctx.variable())); + 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())); + builder.append(TOKEN_COLON); + builder.append(visit(ctx.minute())); + return builder; } @Override - public QueryTokenStream visitJoinRestriction(HqlParser.JoinRestrictionContext ctx) { + public QueryTokenStream visitBinaryLiteral(HqlParser.BinaryLiteralContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.ON() != null) { - builder.append(QueryTokens.expression(ctx.ON())); - } else if (ctx.WITH() != null) { - builder.append(QueryTokens.expression(ctx.WITH())); - } + if (ctx.BINARY_LITERAL() != null) { + builder.append(QueryTokens.expression(ctx.BINARY_LITERAL())); + } else if (ctx.HEX_LITERAL() != null) { - builder.appendExpression(visit(ctx.predicate())); + 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 QueryTokenStream visitJpaCollectionJoin(HqlParser.JpaCollectionJoinContext ctx) { + public QueryTokenStream visitTupleExpression(HqlParser.TupleExpressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(TOKEN_COMMA); - builder.append(QueryTokens.token(ctx.IN())); builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.path())); + builder.append(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); builder.append(TOKEN_CLOSE_PAREN); - if (ctx.variable() != null) { - builder.appendExpression(visit(ctx.variable())); - } - return builder; } @Override - public QueryTokenStream visitGroupByClause(HqlParser.GroupByClauseContext ctx) { + public QueryTokenStream visitHqlConcatenationExpression(HqlParser.HqlConcatenationExpressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.GROUP())); - builder.append(QueryTokens.expression(ctx.BY())); - builder.append(QueryTokenStream.concat(ctx.groupedItem(), this::visit, TOKEN_COMMA)); + builder.appendInline(visit(ctx.expression(0))); + builder.append(TOKEN_DOUBLE_PIPE); + builder.append(visit(ctx.expression(1))); return builder; } @Override - public QueryTokenStream visitOrderByClause(HqlParser.OrderByClauseContext ctx) { + public QueryTokenStream visitGroupedExpression(HqlParser.GroupedExpressionContext ctx) { + return QueryTokenStream.group(visit(ctx.expression())); + } + + @Override + public QueryTokenStream visitSignedNumericLiteral(HqlParser.SignedNumericLiteralContext ctx) { 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)); + builder.append(QueryTokens.token(ctx.op)); + builder.append(visit(ctx.numericLiteral())); return builder; } @Override - public QueryTokenStream visitHavingClause(HqlParser.HavingClauseContext ctx) { + public QueryTokenStream visitSubqueryExpression(HqlParser.SubqueryExpressionContext ctx) { + return QueryTokenStream.group(visit(ctx.subquery())); + } + + @Override + public QueryTokenStream visitSignedExpression(HqlParser.SignedExpressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.HAVING())); - builder.appendExpression(QueryTokenStream.concat(ctx.predicate(), this::visit, TOKEN_COMMA)); + builder.append(QueryTokens.token(ctx.op)); + builder.appendInline(visit(ctx.expression())); return builder; } @Override - public QueryTokenStream visitSetOperator(HqlParser.SetOperatorContext ctx) { + public QueryTokenStream visitSyntacticPathExpression(HqlParser.SyntacticPathExpressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.UNION() != null) { - builder.append(QueryTokens.expression(ctx.UNION())); - } else if (ctx.INTERSECT() != null) { - builder.append(QueryTokens.expression(ctx.INTERSECT())); - } else if (ctx.EXCEPT() != null) { - builder.append(QueryTokens.expression(ctx.EXCEPT())); - } + builder.appendInline(visit(ctx.syntacticDomainPath())); - if (ctx.ALL() != null) { - builder.append(QueryTokens.expression(ctx.ALL())); + if (ctx.pathContinuation() != null) { + builder.appendInline(visit(ctx.pathContinuation())); } return builder; } @Override - public QueryTokenStream visitLiteral(HqlParser.LiteralContext ctx) { - - if (ctx.NULL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.NULL())); - } else if (ctx.booleanLiteral() != null) { - return visit(ctx.booleanLiteral()); - } else if (ctx.JAVA_STRING_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.JAVA_STRING_LITERAL())); - } else if (ctx.STRING_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.STRING_LITERAL())); - } else if (ctx.numericLiteral() != null) { - return visit(ctx.numericLiteral()); - } else if (ctx.temporalLiteral() != null) { - return visit(ctx.temporalLiteral()); - } else if (ctx.arrayLiteral() != null) { - return visit(ctx.arrayLiteral()); - } else if (ctx.generalizedLiteral() != null) { - return visit(ctx.generalizedLiteral()); - } else if (ctx.binaryLiteral() != null) { - return visit(ctx.binaryLiteral()); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitBooleanLiteral(HqlParser.BooleanLiteralContext ctx) { + public QueryTokenStream visitPathContinuation(HqlParser.PathContinuationContext ctx) { - if (ctx.TRUE() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.TRUE())); - } else if (ctx.FALSE() != null) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.FALSE())); - } else { - return QueryTokenStream.empty(); - } - } + QueryRendererBuilder builder = QueryRenderer.builder(); - @Override - public QueryTokenStream visitNumericLiteral(HqlParser.NumericLiteralContext ctx) { + builder.append(TOKEN_DOT); + builder.append(visit(ctx.simplePath())); - if (ctx.INTEGER_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL())); - } else if (ctx.LONG_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.LONG_LITERAL())); - } else if (ctx.BIG_INTEGER_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.BIG_INTEGER_LITERAL())); - } else if (ctx.FLOAT_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.FLOAT_LITERAL())); - } else if (ctx.DOUBLE_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.DOUBLE_LITERAL())); - } else if (ctx.BIG_DECIMAL_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.BIG_DECIMAL_LITERAL())); - } else if (ctx.HEX_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.HEX_LITERAL())); - } else { - return QueryTokenStream.empty(); - } + return builder; } @Override - public QueryTokenStream visitDateTimeLiteral(HqlParser.DateTimeLiteralContext ctx) { + public QueryTokenStream visitEntityTypeReference(HqlParser.EntityTypeReferenceContext ctx) { - if (ctx.localDateTimeLiteral() != null) { - return visit(ctx.localDateTimeLiteral()); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.offsetDateTimeLiteral() != null) { - return visit(ctx.offsetDateTimeLiteral()); + if (ctx.path() != null) { + builder.appendInline(visit(ctx.path())); } - if (ctx.zonedDateTimeLiteral() != null) { - return visit(ctx.zonedDateTimeLiteral()); + if (ctx.parameter() != null) { + builder.appendInline(visit(ctx.parameter())); } - return QueryTokenStream.empty(); + return QueryTokenStream.ofFunction(ctx.TYPE(), builder); } @Override - public QueryTokenStream visitLocalDateTimeLiteral(HqlParser.LocalDateTimeLiteralContext ctx) { + public QueryTokenStream visitEntityIdReference(HqlParser.EntityIdReferenceContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.DATETIME() != null) { - if (ctx.LOCAL() != null) { - builder.append(QueryTokens.expression(ctx.LOCAL())); - } - builder.append(QueryTokens.expression(ctx.DATETIME())); - builder.append(visit(ctx.localDateTime())); - } else { - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.localDateTime())); - builder.append(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.token(ctx.ID())); + 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 builder; } @Override - public QueryTokenStream visitZonedDateTimeLiteral(HqlParser.ZonedDateTimeLiteralContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.DATETIME() != null) { - if (ctx.ZONED() != null) { - builder.append(QueryTokens.expression(ctx.ZONED())); - } - builder.append(QueryTokens.expression(ctx.DATETIME())); - builder.append(visit(ctx.zonedDateTime())); - } else { - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.zonedDateTime())); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; + public QueryTokenStream visitEntityVersionReference(HqlParser.EntityVersionReferenceContext ctx) { + return QueryTokenStream.ofFunction(ctx.VERSION(), visit(ctx.path())); } @Override - public QueryTokenStream visitOffsetDateTimeLiteral(HqlParser.OffsetDateTimeLiteralContext ctx) { + public QueryTokenStream visitEntityNaturalIdReference(HqlParser.EntityNaturalIdReferenceContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.DATETIME() != null) { - if (ctx.OFFSET() != null) { - builder.append(QueryTokens.expression(ctx.OFFSET())); - } - builder.append(QueryTokens.expression(ctx.DATETIME())); - builder.append(visit(ctx.offsetDateTimeWithMinutes())); - } else { - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.offsetDateTime())); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitDateLiteral(HqlParser.DateLiteralContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.DATE() != null) { - if (ctx.LOCAL() != null) { - builder.append(QueryTokens.expression(ctx.LOCAL())); - } - builder.append(QueryTokens.expression(ctx.DATE())); - builder.append(visit(ctx.date())); - } else { - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.date())); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitTimeLiteral(HqlParser.TimeLiteralContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.TIME() != null) { - if (ctx.LOCAL() != null) { - builder.append(QueryTokens.expression(ctx.LOCAL())); - } - builder.append(QueryTokens.expression(ctx.TIME())); - builder.append(visit(ctx.time())); - } else { - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.time())); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitDateTime(HqlParser.DateTimeContext ctx) { - - if (ctx.localDateTime() != null) { - return visit(ctx.localDateTime()); - } - - if (ctx.offsetDateTime() != null) { - return visit(ctx.offsetDateTime()); - } - - if (ctx.zonedDateTime() != null) { - return visit(ctx.zonedDateTime()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitLocalDateTime(HqlParser.LocalDateTimeContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.date())); - builder.appendExpression(visit(ctx.time())); - - return builder; - } - - @Override - public QueryTokenStream visitZonedDateTime(HqlParser.ZonedDateTimeContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.date())); - builder.appendExpression(visit(ctx.time())); - builder.appendExpression(visit(ctx.zoneId())); - - return builder; - } - - @Override - public QueryTokenStream visitOffsetDateTime(HqlParser.OffsetDateTimeContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.date())); - builder.appendInline(visit(ctx.time())); - builder.appendInline(visit(ctx.offset())); - - return builder; - } - - @Override - public QueryTokenStream visitOffsetDateTimeWithMinutes(HqlParser.OffsetDateTimeWithMinutesContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.date())); - builder.appendInline(visit(ctx.time())); - builder.appendInline(visit(ctx.offsetWithMinutes())); - - return builder; - } - - @Override - public QueryTokenStream visitJdbcTimestampLiteral(HqlParser.JdbcTimestampLiteralContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(TOKEN_OPEN_BRACE); - builder.append(QueryTokens.token("ts")); - builder.append(visit(ctx.dateTime() != null ? ctx.dateTime() : ctx.genericTemporalLiteralText())); - builder.append(QueryTokens.TOKEN_CLOSE_BRACE); - - return builder; - } - - @Override - public QueryTokenStream visitJdbcDateLiteral(HqlParser.JdbcDateLiteralContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - 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); - - return builder; - } - - @Override - public QueryTokenStream visitJdbcTimeLiteral(HqlParser.JdbcTimeLiteralContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - 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); - - return builder; - } - - @Override - public QueryTokenStream visitGenericTemporalLiteralText(HqlParser.GenericTemporalLiteralTextContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL())); - } - - @Override - public QueryTokenStream visitArrayLiteral(HqlParser.ArrayLiteralContext ctx) { - - 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); - - return builder; - } - - @Override - public QueryTokenStream visitGeneralizedLiteral(HqlParser.GeneralizedLiteralContext ctx) { - - 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 builder; - } - - @Override - public QueryTokenStream visitGeneralizedLiteralType(HqlParser.GeneralizedLiteralTypeContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL())); - } - - @Override - public QueryTokenStream visitGeneralizedLiteralText(HqlParser.GeneralizedLiteralTextContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL())); - } - - @Override - public QueryTokenStream visitDate(HqlParser.DateContext ctx) { - - 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 builder; - } - - @Override - public QueryTokenStream visitTime(HqlParser.TimeContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(visit(ctx.hour())); - builder.append(TOKEN_COLON); - builder.append(visit(ctx.minute())); - - if (ctx.second() != null) { - builder.append(TOKEN_COLON); - builder.append(visit(ctx.second())); - } - - return builder; - } - - @Override - public QueryTokenStream visitOffset(HqlParser.OffsetContext ctx) { - - 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 QueryTokenStream visitOffsetWithMinutes(HqlParser.OffsetWithMinutesContext ctx) { - - 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())); - builder.append(TOKEN_COLON); - builder.append(visit(ctx.minute())); - - return builder; - } - - @Override - public QueryTokenStream visitYear(HqlParser.YearContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL())); - } - - @Override - public QueryTokenStream visitMonth(HqlParser.MonthContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL())); - } - - @Override - public QueryTokenStream visitDay(HqlParser.DayContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL())); - } - - @Override - public QueryTokenStream visitHour(HqlParser.HourContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL())); - } - - @Override - public QueryTokenStream visitMinute(HqlParser.MinuteContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL())); - } - - @Override - public QueryTokenStream visitSecond(HqlParser.SecondContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL())); - } - - @Override - public QueryTokenStream visitZoneId(HqlParser.ZoneIdContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL())); - } - - @Override - public QueryTokenStream visitDatetimeField(HqlParser.DatetimeFieldContext ctx) { - - if (ctx.YEAR() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.YEAR())); - } else if (ctx.MONTH() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.MONTH())); - } else if (ctx.DAY() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.DAY())); - } else if (ctx.WEEK() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.WEEK())); - } else if (ctx.QUARTER() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.QUARTER())); - } else if (ctx.HOUR() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.HOUR())); - } else if (ctx.MINUTE() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.MINUTE())); - } else if (ctx.SECOND() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.SECOND())); - } else if (ctx.NANOSECOND() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.NANOSECOND())); - } else if (ctx.EPOCH() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.EPOCH())); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitDayField(HqlParser.DayFieldContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.DAY())); - builder.append(QueryTokens.expression(ctx.OF())); - - if (ctx.MONTH() != null) { - builder.append(QueryTokens.expression(ctx.MONTH())); - } - - if (ctx.WEEK() != null) { - builder.append(QueryTokens.expression(ctx.WEEK())); - } - - if (ctx.YEAR() != null) { - builder.append(QueryTokens.expression(ctx.YEAR())); - } - - return builder; - } - - @Override - public QueryTokenStream visitWeekField(HqlParser.WeekFieldContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.WEEK())); - builder.append(QueryTokens.expression(ctx.OF())); - - if (ctx.MONTH() != null) { - builder.append(QueryTokens.expression(ctx.MONTH())); - } - - if (ctx.YEAR() != null) { - builder.append(QueryTokens.expression(ctx.YEAR())); - } - - return builder; - } - - @Override - public QueryTokenStream visitTimeZoneField(HqlParser.TimeZoneFieldContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.OFFSET() != null) { - builder.append(QueryTokens.expression(ctx.OFFSET())); - - if (ctx.HOUR() != null) { - builder.append(QueryTokens.expression(ctx.HOUR())); - } - - if (ctx.MINUTE() != null) { - builder.append(QueryTokens.expression(ctx.MINUTE())); - } - } - - if (ctx.TIMEZONE_HOUR() != null) { - builder.append(QueryTokens.expression(ctx.TIMEZONE_HOUR())); - } - - if (ctx.TIMEZONE_HOUR() != null) { - builder.append(QueryTokens.expression(ctx.TIMEZONE_MINUTE())); - } - - return builder; - } - - @Override - public QueryTokenStream visitDateOrTimeField(HqlParser.DateOrTimeFieldContext ctx) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.DATE() != null ? ctx.DATE() : ctx.TIME())); - } - - @Override - public QueryTokenStream visitBinaryLiteral(HqlParser.BinaryLiteralContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.BINARY_LITERAL() != null) { - builder.append(QueryTokens.expression(ctx.BINARY_LITERAL())); - } else if (ctx.HEX_LITERAL() != null) { - - builder.append(TOKEN_OPEN_BRACE); - - builder.append(QueryTokenStream.concat(ctx.HEX_LITERAL(), it -> { - return QueryRendererBuilder.from(QueryTokens.token(it)); - }, TOKEN_COMMA)); - - builder.append(TOKEN_CLOSE_BRACE); - } - - return builder; - } - - @Override - public QueryTokenStream visitTemporalLiteral(HqlParser.TemporalLiteralContext ctx) { - - if (ctx.dateTimeLiteral() != null) { - return visit(ctx.dateTimeLiteral()); - } - - if (ctx.dateLiteral() != null) { - return visit(ctx.dateLiteral()); - } - - if (ctx.timeLiteral() != null) { - return visit(ctx.timeLiteral()); - } - - if (ctx.jdbcTimestampLiteral() != null) { - return visit(ctx.jdbcTimestampLiteral()); - } - - if (ctx.jdbcDateLiteral() != null) { - return visit(ctx.jdbcDateLiteral()); - } - - if (ctx.jdbcTimeLiteral() != null) { - return visit(ctx.jdbcTimeLiteral()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitPlainPrimaryExpression(HqlParser.PlainPrimaryExpressionContext ctx) { - return visit(ctx.primaryExpression()); - } - - @Override - public QueryTokenStream visitTupleExpression(HqlParser.TupleExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(TOKEN_OPEN_PAREN); - builder.append(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitHqlConcatenationExpression(HqlParser.HqlConcatenationExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.expression(0))); - builder.append(TOKEN_DOUBLE_PIPE); - builder.append(visit(ctx.expression(1))); - - return builder; - } - - @Override - public QueryTokenStream visitDayOfWeekExpression(HqlParser.DayOfWeekExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.DAY())); - builder.append(QueryTokens.expression(ctx.OF())); - builder.append(QueryTokens.expression(ctx.WEEK())); - - return builder; - } - - @Override - public QueryTokenStream visitDayOfMonthExpression(HqlParser.DayOfMonthExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.DAY())); - builder.append(QueryTokens.expression(ctx.OF())); - builder.append(QueryTokens.expression(ctx.MONTH())); - - return builder; - } - - @Override - public QueryTokenStream visitWeekOfYearExpression(HqlParser.WeekOfYearExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.WEEK())); - builder.append(QueryTokens.expression(ctx.OF())); - builder.append(QueryTokens.expression(ctx.YEAR())); - - return builder; - } - - @Override - public QueryTokenStream visitGroupedExpression(HqlParser.GroupedExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.expression())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitAdditionExpression(HqlParser.AdditionExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.expression(0))); - builder.append(QueryTokens.ventilated(ctx.op)); - builder.appendInline(visit(ctx.expression(1))); - - return builder; - } - - @Override - public QueryTokenStream visitSignedNumericLiteral(HqlParser.SignedNumericLiteralContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.op)); - builder.append(visit(ctx.numericLiteral())); - - return builder; - } - - @Override - public QueryTokenStream visitMultiplicationExpression(HqlParser.MultiplicationExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.expression(0))); - builder.append(QueryTokens.expression(ctx.op)); - builder.appendExpression(visit(ctx.expression(1))); - - return builder; - } - - @Override - public QueryTokenStream visitSubqueryExpression(HqlParser.SubqueryExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitSignedExpression(HqlParser.SignedExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.op)); - builder.appendInline(visit(ctx.expression())); - - return builder; - } - - @Override - public QueryTokenStream visitToDurationExpression(HqlParser.ToDurationExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.expression())); - builder.appendExpression(visit(ctx.datetimeField())); - - return builder; - } - - @Override - public QueryTokenStream visitFromDurationExpression(HqlParser.FromDurationExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.expression())); - builder.append(QueryTokens.expression(ctx.BY())); - builder.appendExpression(visit(ctx.datetimeField())); - - return builder; - } - - @Override - public QueryTokenStream visitCaseExpression(HqlParser.CaseExpressionContext ctx) { - return visit(ctx.caseList()); - } - - @Override - public QueryTokenStream visitLiteralExpression(HqlParser.LiteralExpressionContext ctx) { - return visit(ctx.literal()); - } - - @Override - public QueryTokenStream visitParameterExpression(HqlParser.ParameterExpressionContext ctx) { - return visit(ctx.parameter()); - } - - @Override - public QueryTokenStream visitEntityTypeExpression(HqlParser.EntityTypeExpressionContext ctx) { - return visit(ctx.entityTypeReference()); - } - - @Override - public QueryTokenStream visitEntityIdExpression(HqlParser.EntityIdExpressionContext ctx) { - return visit(ctx.entityIdReference()); - } - - @Override - public QueryTokenStream visitEntityVersionExpression(HqlParser.EntityVersionExpressionContext ctx) { - return visit(ctx.entityVersionReference()); - } - - @Override - public QueryTokenStream visitEntityNaturalIdExpression(HqlParser.EntityNaturalIdExpressionContext ctx) { - return visit(ctx.entityNaturalIdReference()); - } - - @Override - public QueryTokenStream visitSyntacticPathExpression(HqlParser.SyntacticPathExpressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.syntacticDomainPath())); - - if (ctx.pathContinuation() != null) { - builder.appendInline(visit(ctx.pathContinuation())); - } - - return builder; - } - - @Override - public QueryTokenStream visitPathContinuation(HqlParser.PathContinuationContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(TOKEN_DOT); - builder.append(visit(ctx.simplePath())); - - return builder; - } - - @Override - public QueryTokenStream visitEntityTypeReference(HqlParser.EntityTypeReferenceContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.TYPE())); - builder.append(TOKEN_OPEN_PAREN); - - if (ctx.path() != null) { - builder.appendInline(visit(ctx.path())); - } - - if (ctx.parameter() != null) { - builder.appendInline(visit(ctx.parameter())); - } - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitEntityIdReference(HqlParser.EntityIdReferenceContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.ID())); - 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 builder; - } - - @Override - public QueryTokenStream visitEntityVersionReference(HqlParser.EntityVersionReferenceContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.VERSION())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.path())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitEntityNaturalIdReference(HqlParser.EntityNaturalIdReferenceContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - 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 builder; - } - - @Override - public QueryTokenStream visitSyntacticDomainPath(HqlParser.SyntacticDomainPathContext ctx) { - - if (ctx.treatedNavigablePath() != null) { - return visit(ctx.treatedNavigablePath()); - } - - if (ctx.collectionValueNavigablePath() != null) { - return visit(ctx.collectionValueNavigablePath()); - } - - if (ctx.mapKeyNavigablePath() != null) { - return visit(ctx.mapKeyNavigablePath()); - } - - if (ctx.toOneFkReference() != null) { - return visit(ctx.toOneFkReference()); - } - - if (ctx.function() != null) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.function())); - - if (ctx.indexedPathAccessFragment() != null) { - builder.append(visit(ctx.indexedPathAccessFragment())); - } - - if (ctx.slicedPathAccessFragment() != null) { - builder.append(visit(ctx.slicedPathAccessFragment())); - } - - if (ctx.pathContinuation() != null) { - builder.append(visit(ctx.pathContinuation())); - } - - return builder; - } - - if (ctx.indexedPathAccessFragment() != null) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.simplePath())); - builder.append(visit(ctx.indexedPathAccessFragment())); - - return builder; - } - - if (ctx.slicedPathAccessFragment() != null) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.simplePath())); - builder.append(visit(ctx.slicedPathAccessFragment())); - - return builder; - } - - return QueryRenderer.empty(); - } - - @Override - public QueryTokenStream visitSlicedPathAccessFragment(HqlParser.SlicedPathAccessFragmentContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - 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 builder; - } - - @Override - public QueryTokenStream visitFunctionExpression(HqlParser.FunctionExpressionContext ctx) { - return visit(ctx.function()); - } - - @Override - public QueryTokenStream visitStandardFunctionInvocation(HqlParser.StandardFunctionInvocationContext ctx) { - return visit(ctx.standardFunction()); - } - - @Override - public QueryTokenStream visitAggregateFunctionInvocation(HqlParser.AggregateFunctionInvocationContext ctx) { - return visit(ctx.aggregateFunction()); - } - - @Override - public QueryTokenStream visitCollectionSizeFunctionInvocation(HqlParser.CollectionSizeFunctionInvocationContext ctx) { - return visit(ctx.collectionSizeFunction()); - } - - @Override - public QueryTokenStream visitCollectionAggregateFunctionInvocation( - HqlParser.CollectionAggregateFunctionInvocationContext ctx) { - return visit(ctx.collectionAggregateFunction()); - } - - @Override - public QueryTokenStream visitCollectionFunctionMisuseInvocation( - HqlParser.CollectionFunctionMisuseInvocationContext ctx) { - return visit(ctx.collectionFunctionMisuse()); - } - - @Override - public QueryTokenStream visitJpaNonstandardFunctionInvocation(HqlParser.JpaNonstandardFunctionInvocationContext ctx) { - return visit(ctx.jpaNonstandardFunction()); - } - - @Override - public QueryTokenStream visitColumnFunctionInvocation(HqlParser.ColumnFunctionInvocationContext ctx) { - return visit(ctx.columnFunction()); - } - - @Override - public QueryTokenStream visitGenericFunctionInvocation(HqlParser.GenericFunctionInvocationContext ctx) { - return visit(ctx.genericFunction()); - } - - @Override - public QueryTokenStream visitStandardFunction(HqlParser.StandardFunctionContext ctx) { - - if (ctx.castFunction() != null) { - return visit(ctx.castFunction()); - } - - if (ctx.extractFunction() != null) { - return visit(ctx.extractFunction()); - } - - if (ctx.truncFunction() != null) { - return visit(ctx.truncFunction()); - } - - if (ctx.formatFunction() != null) { - return visit(ctx.formatFunction()); - } - - if (ctx.collateFunction() != null) { - return visit(ctx.collateFunction()); - } - - if (ctx.substringFunction() != null) { - return visit(ctx.substringFunction()); - } - - if (ctx.overlayFunction() != null) { - return visit(ctx.overlayFunction()); - } - - if (ctx.trimFunction() != null) { - return visit(ctx.trimFunction()); - } - - if (ctx.padFunction() != null) { - return visit(ctx.padFunction()); - } - - if (ctx.positionFunction() != null) { - return visit(ctx.positionFunction()); - } - - if (ctx.currentDateFunction() != null) { - return visit(ctx.currentDateFunction()); - } - - if (ctx.currentTimeFunction() != null) { - return visit(ctx.currentTimeFunction()); - } - - if (ctx.currentTimestampFunction() != null) { - return visit(ctx.currentTimestampFunction()); - } - - if (ctx.instantFunction() != null) { - return visit(ctx.instantFunction()); - } - - if (ctx.localDateFunction() != null) { - return visit(ctx.localDateFunction()); - } - - if (ctx.localTimeFunction() != null) { - return visit(ctx.localTimeFunction()); - } - - if (ctx.localDateTimeFunction() != null) { - return visit(ctx.localDateTimeFunction()); - } - - if (ctx.offsetDateTimeFunction() != null) { - return visit(ctx.offsetDateTimeFunction()); - } - - if (ctx.cube() != null) { - return visit(ctx.cube()); - } - - if (ctx.rollup() != null) { - return visit(ctx.rollup()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitSubstringFunction(HqlParser.SubstringFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.SUBSTRING())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.expression())); - - if (ctx.FROM() == null) { - builder.append(TOKEN_COMMA); - } else { - builder.append(QueryTokens.expression(ctx.FROM())); - } - - builder.append(visit(ctx.substringFunctionStartArgument())); - - if (ctx.substringFunctionLengthArgument() != null) { - if (ctx.FOR() == null) { - builder.append(TOKEN_COMMA); - } else { - builder.append(QueryTokens.expression(ctx.FOR())); - } - - builder.append(visit(ctx.substringFunctionLengthArgument())); - } - - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitSubstringFunctionStartArgument(HqlParser.SubstringFunctionStartArgumentContext ctx) { - return visit(ctx.expression()); - } - - @Override - public QueryTokenStream visitSubstringFunctionLengthArgument(HqlParser.SubstringFunctionLengthArgumentContext ctx) { - return visit(ctx.expression()); - } - - @Override - public QueryTokenStream visitPadFunction(HqlParser.PadFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.PAD())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.expression())); - builder.append(QueryTokens.expression(ctx.WITH())); - builder.appendExpression(visit(ctx.padLength())); - - if (ctx.padCharacter() != null) { - builder.appendExpression(visit(ctx.padSpecification())); - builder.appendInline(visit(ctx.padCharacter())); - } else { - builder.append(visit(ctx.padSpecification())); - } - - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitPadSpecification(HqlParser.PadSpecificationContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.LEADING() != null ? ctx.LEADING() : ctx.TRAILING())); - } - - @Override - public QueryTokenStream visitPadCharacter(HqlParser.PadCharacterContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL())); - } - - @Override - public QueryTokenStream visitPadLength(HqlParser.PadLengthContext ctx) { - return visit(ctx.expression()); - } - - @Override - public QueryTokenStream visitPositionFunction(HqlParser.PositionFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.POSITION())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.positionFunctionPatternArgument())); - builder.append(QueryTokens.expression(ctx.IN())); - builder.appendInline(visit(ctx.positionFunctionStringArgument())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitPositionFunctionPatternArgument(HqlParser.PositionFunctionPatternArgumentContext ctx) { - return visit(ctx.expression()); - } - - @Override - public QueryTokenStream visitPositionFunctionStringArgument(HqlParser.PositionFunctionStringArgumentContext ctx) { - return visit(ctx.expression()); - } - - @Override - public QueryTokenStream visitOverlayFunction(HqlParser.OverlayFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.OVERLAY())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(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())); - - if (ctx.overlayFunctionLengthArgument() != null) { - builder.append(QueryTokens.expression(ctx.FOR())); - builder.append(visit(ctx.overlayFunctionLengthArgument())); - } - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitOverlayFunctionStringArgument(HqlParser.OverlayFunctionStringArgumentContext ctx) { - return visit(ctx.expression()); - } - - @Override - public QueryTokenStream visitOverlayFunctionReplacementArgument( - HqlParser.OverlayFunctionReplacementArgumentContext ctx) { - return visit(ctx.expression()); - } - - @Override - public QueryTokenStream visitOverlayFunctionStartArgument(HqlParser.OverlayFunctionStartArgumentContext ctx) { - return visit(ctx.expression()); - } - - @Override - public QueryTokenStream visitOverlayFunctionLengthArgument(HqlParser.OverlayFunctionLengthArgumentContext ctx) { - return visit(ctx.expression()); - } - - @Override - public QueryTokenStream visitCurrentDateFunction(HqlParser.CurrentDateFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.CURRENT_DATE() != null) { - builder.append(QueryTokens.token(ctx.CURRENT_DATE())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } else { - builder.append(QueryTokens.expression(ctx.CURRENT())); - builder.append(QueryTokens.expression(ctx.DATE())); - } - - return builder; - } - - @Override - public QueryTokenStream visitCurrentTimeFunction(HqlParser.CurrentTimeFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.CURRENT_TIME() != null) { - builder.append(QueryTokens.token(ctx.CURRENT_TIME())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } else { - builder.append(QueryTokens.expression(ctx.CURRENT())); - builder.append(QueryTokens.expression(ctx.TIME())); - } - - return builder; - } - - @Override - public QueryTokenStream visitCurrentTimestampFunction(HqlParser.CurrentTimestampFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.CURRENT_TIMESTAMP() != null) { - builder.append(QueryTokens.token(ctx.CURRENT_TIMESTAMP())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } else { - builder.append(QueryTokens.expression(ctx.CURRENT())); - builder.append(QueryTokens.expression(ctx.TIMESTAMP())); - } - - return builder; - } - - @Override - public QueryTokenStream visitInstantFunction(HqlParser.InstantFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.CURRENT_INSTANT() != null) { - builder.append(QueryTokens.token(ctx.CURRENT_INSTANT())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } else { - builder.append(QueryTokens.expression(ctx.INSTANT())); - } - - return builder; - } - - @Override - public QueryTokenStream visitLocalDateTimeFunction(HqlParser.LocalDateTimeFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.LOCAL_DATETIME() != null) { - builder.append(QueryTokens.token(ctx.LOCAL_DATETIME())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } else { - builder.append(QueryTokens.expression(ctx.LOCAL())); - builder.append(QueryTokens.expression(ctx.DATETIME())); - } - - return builder; - } - - @Override - public QueryTokenStream visitOffsetDateTimeFunction(HqlParser.OffsetDateTimeFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.OFFSET_DATETIME() != null) { - builder.append(QueryTokens.token(ctx.OFFSET_DATETIME())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } else { - builder.append(QueryTokens.expression(ctx.OFFSET())); - builder.append(QueryTokens.expression(ctx.DATETIME())); - } - - return builder; - } - - @Override - public QueryTokenStream visitLocalDateFunction(HqlParser.LocalDateFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.LOCAL_DATE() != null) { - builder.append(QueryTokens.token(ctx.LOCAL_DATE())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } else { - builder.append(QueryTokens.expression(ctx.LOCAL())); - builder.append(QueryTokens.expression(ctx.DATE())); - } - - return builder; - } - - @Override - public QueryTokenStream visitLocalTimeFunction(HqlParser.LocalTimeFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.LOCAL_TIME() != null) { - builder.append(QueryTokens.token(ctx.LOCAL_TIME())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } else { - builder.append(QueryTokens.expression(ctx.LOCAL())); - builder.append(QueryTokens.expression(ctx.TIME())); - } - - return builder; - } - - @Override - public QueryTokenStream visitFormatFunction(HqlParser.FormatFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.FORMAT())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.expression())); - builder.append(QueryTokens.expression(ctx.AS())); - builder.appendInline(visit(ctx.format())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitCollation(HqlParser.CollationContext ctx) { - return visit(ctx.simplePath()); - } - - @Override - public QueryTokenStream visitCollateFunction(HqlParser.CollateFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.COLLATE())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.expression())); - builder.append(QueryTokens.expression(ctx.AS())); - builder.appendInline(visit(ctx.collation())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitCube(HqlParser.CubeContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.CUBE())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitRollup(HqlParser.RollupContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.ROLLUP())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitFormat(HqlParser.FormatContext ctx) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL())); - } - - @Override - public QueryTokenStream visitTruncFunction(HqlParser.TruncFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.TRUNC() != null) { - builder.append(QueryTokens.token(ctx.TRUNC())); - } else { - builder.append(QueryTokens.token(ctx.TRUNCATE())); - } - - builder.append(TOKEN_OPEN_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 builder; - } - - @Override - public QueryTokenStream visitJpaNonstandardFunction(HqlParser.JpaNonstandardFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.FUNCTION())); - builder.append(TOKEN_OPEN_PAREN); - - QueryRendererBuilder nested = QueryRenderer.builder(); - nested.appendInline(visit(ctx.jpaNonstandardFunctionName())); - - if (ctx.castTarget() != null) { - nested.append(QueryTokens.expression(ctx.AS())); - nested.append(visit(ctx.castTarget())); - } - - if (ctx.genericFunctionArguments() != null) { - nested.append(TOKEN_COMMA); - nested.appendInline(visit(ctx.genericFunctionArguments())); - } - - builder.appendInline(nested); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitJpaNonstandardFunctionName(HqlParser.JpaNonstandardFunctionNameContext ctx) { - - if (ctx.identifier() != null) { - return visit(ctx.identifier()); - } - - return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL())); - } - - @Override - public QueryTokenStream visitColumnFunction(HqlParser.ColumnFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.COLUMN())); - builder.append(TOKEN_OPEN_PAREN); - - QueryRendererBuilder nested = QueryRenderer.builder(); - nested.appendInline(visit(ctx.path())); - nested.append(TOKEN_DOT); - nested.appendExpression(visit(ctx.jpaNonstandardFunctionName())); - - if (ctx.castTarget() != null) { - nested.append(QueryTokens.expression(ctx.AS())); - nested.appendExpression(visit(ctx.jpaNonstandardFunctionName())); - } - - builder.appendInline(nested); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitGenericFunctionName(HqlParser.GenericFunctionNameContext ctx) { - return visit(ctx.simplePath()); - } - - @Override - public QueryTokenStream visitGenericFunctionArguments(HqlParser.GenericFunctionArgumentsContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.DISTINCT() != null) { - builder.append(QueryTokens.expression(ctx.DISTINCT())); - } - - if (ctx.datetimeField() != null) { - builder.append(visit(ctx.datetimeField())); - builder.append(TOKEN_COMMA); - } - - builder.append(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); - - return builder; - } - - @Override - public QueryTokenStream visitCollectionSizeFunction(HqlParser.CollectionSizeFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.SIZE())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.path())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitElementAggregateFunction(HqlParser.ElementAggregateFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - 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 { - - 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())); - } - - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.elementsValuesQuantifier())); - builder.append(TOKEN_OPEN_PAREN); - - if (ctx.path() != null) { - builder.append(visit(ctx.path())); - } - - builder.append(TOKEN_CLOSE_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitIndexAggregateFunction(HqlParser.IndexAggregateFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - 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 { - - 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())); - } - - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.indicesKeysQuantifier())); - builder.append(TOKEN_OPEN_PAREN); - - if (ctx.path() != null) { - builder.append(visit(ctx.path())); - } - - builder.append(TOKEN_CLOSE_PAREN); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitCollectionFunctionMisuse(HqlParser.CollectionFunctionMisuseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append( - visit(ctx.elementsValuesQuantifier() != null ? ctx.elementsValuesQuantifier() : ctx.indicesKeysQuantifier())); + builder.append(QueryTokens.token(ctx.NATURALID())); builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.path())); + builder.appendInline(visit(ctx.path())); builder.append(TOKEN_CLOSE_PAREN); + if (ctx.pathContinuation() != null) { + builder.appendInline(visit(ctx.pathContinuation())); + } + return builder; } @Override - public QueryTokenStream visitAggregateFunction(HqlParser.AggregateFunctionContext ctx) { + public QueryTokenStream visitSyntacticDomainPath(HqlParser.SyntacticDomainPathContext ctx) { - if (ctx.everyFunction() != null) { - return visit(ctx.everyFunction()); + if (ctx.treatedNavigablePath() != null) { + return visit(ctx.treatedNavigablePath()); } - if (ctx.anyFunction() != null) { - return visit(ctx.anyFunction()); + if (ctx.collectionValueNavigablePath() != null) { + return visit(ctx.collectionValueNavigablePath()); } - return visit(ctx.listaggFunction()); - } - - @Override - public QueryTokenStream visitEveryAllQuantifier(HqlParser.EveryAllQuantifierContext ctx) { - - if (ctx.EVERY() != null) { - return QueryRenderer.from(QueryTokens.token(ctx.EVERY())); + if (ctx.mapKeyNavigablePath() != null) { + return visit(ctx.mapKeyNavigablePath()); } - return QueryRenderer.from(QueryTokens.token(ctx.ALL())); - } - - @Override - public QueryTokenStream visitAnySomeQuantifier(HqlParser.AnySomeQuantifierContext ctx) { - - if (ctx.ANY() != null) { - return QueryRenderer.from(QueryTokens.token(ctx.ANY())); + if (ctx.toOneFkReference() != null) { + return visit(ctx.toOneFkReference()); } - return QueryRenderer.from(QueryTokens.token(ctx.SOME())); - } - - @Override - public QueryTokenStream visitListaggFunction(HqlParser.ListaggFunctionContext ctx) { + if (ctx.function() != null) { - QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.LISTAGG())); - builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.function())); - QueryRendererBuilder nested = QueryRenderer.builder(); + if (ctx.indexedPathAccessFragment() != null) { + builder.append(visit(ctx.indexedPathAccessFragment())); + } - if (ctx.DISTINCT() != null) { - builder.append(QueryTokens.expression(ctx.DISTINCT())); - } + if (ctx.slicedPathAccessFragment() != null) { + builder.append(visit(ctx.slicedPathAccessFragment())); + } - builder.appendInline(visit(ctx.expressionOrPredicate(0))); - builder.append(TOKEN_COMMA); - builder.appendInline(visit(ctx.expressionOrPredicate(1))); + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); + } - if (ctx.onOverflowClause() != null) { - builder.appendExpression(visit(ctx.onOverflowClause())); + return builder; } - builder.appendInline(nested); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.indexedPathAccessFragment() != null) { - if (ctx.withinGroupClause() != null) { - builder.appendExpression(visit(ctx.withinGroupClause())); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.filterClause() != null) { - builder.appendExpression(visit(ctx.filterClause())); - } + builder.append(visit(ctx.simplePath())); + builder.append(visit(ctx.indexedPathAccessFragment())); - if (ctx.overClause() != null) { - builder.appendExpression(visit(ctx.overClause())); + return builder; } - return builder; - } - - @Override - public QueryTokenStream visitOnOverflowClause(HqlParser.OnOverflowClauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.ON())); - builder.append(QueryTokens.expression(ctx.OVERFLOW())); - - if (ctx.ERROR() != null) { - builder.append(QueryTokens.expression(ctx.ERROR())); - } else { - - builder.append(QueryTokens.expression(ctx.TRUNCATE())); - - if (ctx.expression() != null) { - builder.appendExpression(visit(ctx.expression())); - } + if (ctx.slicedPathAccessFragment() != null) { - if (ctx.WITH() != null) { - builder.append(QueryTokens.expression(ctx.WITH())); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.WITHOUT() != null) { - builder.append(QueryTokens.expression(ctx.WITHOUT())); - } + builder.append(visit(ctx.simplePath())); + builder.append(visit(ctx.slicedPathAccessFragment())); - if (ctx.COUNT() != null) { - builder.append(QueryTokens.expression(ctx.COUNT())); - } + return builder; } - return builder; + return QueryRenderer.empty(); } @Override - public QueryTokenStream visitWithinGroupClause(HqlParser.WithinGroupClauseContext ctx) { + public QueryTokenStream visitSlicedPathAccessFragment(HqlParser.SlicedPathAccessFragmentContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.WITHIN())); - builder.append(QueryTokens.expression(ctx.GROUP())); - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.orderByClause())); - builder.append(TOKEN_CLOSE_PAREN); + 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 builder; } @Override - public QueryTokenStream visitNullsClause(HqlParser.NullsClauseContext ctx) { + public QueryTokenStream visitSubstringFunction(HqlParser.SubstringFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.IGNORE() != null) { - builder.append(QueryTokens.expression(ctx.IGNORE())); + if (ctx.FROM() == null) { + builder.appendInline(visit(ctx.expression())); + builder.append(TOKEN_COMMA); } else { - builder.append(QueryTokens.expression(ctx.RESPECT())); + builder.appendExpression(visit(ctx.expression())); + builder.append(QueryTokens.expression(ctx.FROM())); } - builder.append(QueryTokens.expression(ctx.NULLS())); - - return builder; - } - - @Override - public QueryTokenStream visitNthSideClause(HqlParser.NthSideClauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.substringFunctionLengthArgument() != null) { - builder.append(QueryTokens.expression(ctx.FROM())); + 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())); + } - if (ctx.FIRST() != null) { - builder.append(QueryTokens.expression(ctx.FIRST())); + builder.append(visit(ctx.substringFunctionLengthArgument())); } else { - builder.append(QueryTokens.expression(ctx.LAST())); + builder.appendExpression(visit(ctx.substringFunctionStartArgument())); } - return builder; + return QueryTokenStream.ofFunction(ctx.SUBSTRING(), builder); } @Override - public QueryTokenStream visitFrameStart(HqlParser.FrameStartContext ctx) { + public QueryTokenStream visitPadFunction(HqlParser.PadFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.CURRENT() != null) { + builder.appendExpression(visit(ctx.expression())); + builder.append(QueryTokens.expression(ctx.WITH())); + builder.appendExpression(visit(ctx.padLength())); - builder.append(QueryTokens.expression(ctx.CURRENT())); - builder.append(QueryTokens.expression(ctx.ROW())); - } else if (ctx.UNBOUNDED() != null) { - builder.append(QueryTokens.expression(ctx.UNBOUNDED())); - builder.append(QueryTokens.expression(ctx.PRECEDING())); + if (ctx.padCharacter() != null) { + builder.appendExpression(visit(ctx.padSpecification())); + builder.appendExpression(visit(ctx.padCharacter())); } else { - - builder.appendExpression(visit(ctx.expression())); - builder.append(QueryTokens.expression(ctx.PRECEDING() != null ? ctx.PRECEDING() : ctx.FOLLOWING())); + builder.appendExpression(visit(ctx.padSpecification())); } - return builder; - + return QueryTokenStream.ofFunction(ctx.PAD(), builder); } @Override - public QueryTokenStream visitFrameEnd(HqlParser.FrameEndContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.CURRENT() != null) { + public QueryTokenStream visitPositionFunction(HqlParser.PositionFunctionContext ctx) { - builder.append(QueryTokens.expression(ctx.CURRENT())); - builder.append(QueryTokens.expression(ctx.ROW())); - } else if (ctx.UNBOUNDED() != null) { - builder.append(QueryTokens.expression(ctx.UNBOUNDED())); - builder.append(QueryTokens.expression(ctx.FOLLOWING())); - } else { + QueryRendererBuilder nested = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.expression())); - builder.append(QueryTokens.expression(ctx.PRECEDING() != null ? ctx.PRECEDING() : ctx.FOLLOWING())); - } + nested.appendExpression(visit(ctx.positionFunctionPatternArgument())); + nested.append(QueryTokens.expression(ctx.IN())); + nested.append(visit(ctx.positionFunctionStringArgument())); - return builder; + return QueryTokenStream.ofFunction(ctx.POSITION(), nested); } @Override - public QueryTokenStream visitFrameExclusion(HqlParser.FrameExclusionContext ctx) { + public QueryTokenStream visitOverlayFunction(HqlParser.OverlayFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.EXCLUDE())); + 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())); - if (ctx.CURRENT() != null) { - builder.append(QueryTokens.expression(ctx.CURRENT())); - builder.append(QueryTokens.expression(ctx.ROW())); - } else if (ctx.GROUP() != null) { - builder.append(QueryTokens.expression(ctx.GROUP())); - } else if (ctx.TIES() != null) { - builder.append(QueryTokens.expression(ctx.TIES())); - } else { - builder.append(QueryTokens.expression(ctx.NO())); - builder.append(QueryTokens.expression(ctx.OTHERS())); + if (ctx.overlayFunctionLengthArgument() != null) { + builder.append(QueryTokens.expression(ctx.FOR())); + builder.append(visit(ctx.overlayFunctionLengthArgument())); } + builder.append(TOKEN_CLOSE_PAREN); return builder; } @Override - public QueryTokenStream visitCollectionQuantifier(HqlParser.CollectionQuantifierContext ctx) { + public QueryTokenStream visitCurrentDateFunction(HqlParser.CurrentDateFunctionContext ctx) { - if (ctx.elementsValuesQuantifier() != null) { - return visit(ctx.elementsValuesQuantifier()); + if (ctx.CURRENT_DATE() != null) { + return QueryTokenStream.ofFunction(ctx.CURRENT_DATE(), QueryTokenStream.empty()); } - return visit(ctx.indicesKeysQuantifier()); + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitElementsValuesQuantifier(HqlParser.ElementsValuesQuantifierContext ctx) { - return QueryRenderer.from(QueryTokens.token(ctx.ELEMENTS() != null ? ctx.ELEMENTS() : ctx.VALUES())); - } + public QueryTokenStream visitCurrentTimeFunction(HqlParser.CurrentTimeFunctionContext ctx) { - @Override - public QueryTokenStream visitIndicesKeysQuantifier(HqlParser.IndicesKeysQuantifierContext ctx) { - return QueryRenderer.from(QueryTokens.token(ctx.INDICES() != null ? ctx.INDICES() : ctx.KEYS())); - } + if (ctx.CURRENT_TIME() != null) { + return QueryTokenStream.ofFunction(ctx.CURRENT_TIME(), QueryTokenStream.empty()); + } - @Override - public QueryTokenStream visitGeneralPathExpression(HqlParser.GeneralPathExpressionContext ctx) { - return visit(ctx.generalPathFragment()); + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitPath(HqlParser.PathContext ctx) { + public QueryTokenStream visitCurrentTimestampFunction(HqlParser.CurrentTimestampFunctionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.CURRENT_TIMESTAMP() != null) { + return QueryTokenStream.ofFunction(ctx.CURRENT_TIMESTAMP(), QueryTokenStream.empty()); + } - if (ctx.syntacticDomainPath() != null) { + return QueryTokenStream.concatExpressions(ctx.children, this::visit); + } - builder.append(visit(ctx.syntacticDomainPath())); + @Override + public QueryTokenStream visitInstantFunction(HqlParser.InstantFunctionContext ctx) { - if (ctx.pathContinuation() != null) { - builder.append(visit(ctx.pathContinuation())); - } - } else if (ctx.generalPathFragment() != null) { - builder.append(visit(ctx.generalPathFragment())); + if (ctx.CURRENT_INSTANT() != null) { + return QueryTokenStream.ofFunction(ctx.CURRENT_INSTANT(), QueryTokenStream.empty()); } - return builder; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitGeneralPathFragment(HqlParser.GeneralPathFragmentContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.simplePath())); + public QueryTokenStream visitLocalDateTimeFunction(HqlParser.LocalDateTimeFunctionContext ctx) { - if (ctx.indexedPathAccessFragment() != null) { - builder.append(visit(ctx.indexedPathAccessFragment())); + if (ctx.LOCAL_DATETIME() != null) { + return QueryTokenStream.ofFunction(ctx.LOCAL_DATETIME(), QueryTokenStream.empty()); } - return builder; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitIndexedPathAccessFragment(HqlParser.IndexedPathAccessFragmentContext ctx) { + public QueryTokenStream visitOffsetDateTimeFunction(HqlParser.OffsetDateTimeFunctionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.OFFSET_DATETIME() != null) { + return QueryTokenStream.ofFunction(ctx.OFFSET_DATETIME(), QueryTokenStream.empty()); + } - builder.append(TOKEN_OPEN_SQUARE_BRACKET); - builder.appendInline(visit(ctx.expression())); - builder.append(TOKEN_CLOSE_SQUARE_BRACKET); + return QueryTokenStream.concatExpressions(ctx.children, this::visit); + } - if (ctx.generalPathFragment() != null) { + @Override + public QueryTokenStream visitLocalDateFunction(HqlParser.LocalDateFunctionContext ctx) { - builder.append(TOKEN_DOT); - builder.append(visit(ctx.generalPathFragment())); + if (ctx.LOCAL_DATE() != null) { + return QueryTokenStream.ofFunction(ctx.LOCAL_DATE(), QueryTokenStream.empty()); } - return builder; + return QueryTokenStream.concatExpressions(ctx.children, this::visit); } @Override - public QueryTokenStream visitSimplePath(HqlParser.SimplePathContext ctx) { + public QueryTokenStream visitLocalTimeFunction(HqlParser.LocalTimeFunctionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.LOCAL_TIME() != null) { + return QueryTokenStream.ofFunction(ctx.LOCAL_TIME(), QueryTokenStream.empty()); + } - builder.append(visit(ctx.identifier())); + return QueryTokenStream.concatExpressions(ctx.children, this::visit); + } - if (!ctx.simplePathElement().isEmpty()) { - builder.append(TOKEN_DOT); - } + @Override + public QueryTokenStream visitFormatFunction(HqlParser.FormatFunctionContext ctx) { - builder.append(QueryTokenStream.concat(ctx.simplePathElement(), this::visit, TOKEN_DOT)); + QueryRendererBuilder args = QueryRenderer.builder(); - return builder; + args.appendExpression(visit(ctx.expression())); + args.append(QueryTokens.expression(ctx.AS())); + args.appendExpression(visit(ctx.format())); + + return QueryTokenStream.ofFunction(ctx.FORMAT(), args); } @Override - public QueryTokenStream visitSimplePathElement(HqlParser.SimplePathElementContext ctx) { + public QueryTokenStream visitCollateFunction(HqlParser.CollateFunctionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder args = QueryRenderer.builder(); - builder.append(visit(ctx.identifier())); + args.appendExpression(visit(ctx.expression())); + args.append(QueryTokens.expression(ctx.AS())); + args.appendExpression(visit(ctx.collation())); - return builder; + return QueryTokenStream.ofFunction(ctx.COLLATE(), args); } @Override - public QueryTokenStream visitCaseList(HqlParser.CaseListContext ctx) { + public QueryTokenStream visitCube(HqlParser.CubeContext ctx) { + return QueryTokenStream.ofFunction(ctx.CUBE(), + QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); + } - if (ctx.simpleCaseExpression() != null) { - return visit(ctx.simpleCaseExpression()); - } else if (ctx.searchedCaseExpression() != null) { - return visit(ctx.searchedCaseExpression()); - } else { - return QueryTokenStream.empty(); - } + @Override + public QueryTokenStream visitRollup(HqlParser.RollupContext ctx) { + return QueryTokenStream.ofFunction(ctx.ROLLUP(), + QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); } @Override - public QueryTokenStream visitSimpleCaseExpression(HqlParser.SimpleCaseExpressionContext ctx) { + public QueryTokenStream visitTruncFunction(HqlParser.TruncFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.CASE())); - builder.append(visit(ctx.expressionOrPredicate(0))); - - ctx.caseWhenExpressionClause().forEach(caseWhenExpressionClauseContext -> { - builder.append(visit(caseWhenExpressionClauseContext)); - }); + if (ctx.TRUNC() != null) { + builder.append(QueryTokens.token(ctx.TRUNC())); + } else { + builder.append(QueryTokens.token(ctx.TRUNCATE())); + } - if (ctx.ELSE() != null) { + builder.append(TOKEN_OPEN_PAREN); - builder.append(QueryTokens.expression(ctx.ELSE())); - builder.append(visit(ctx.expressionOrPredicate(1))); + 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(QueryTokens.expression(ctx.END())); + builder.append(TOKEN_CLOSE_PAREN); return builder; } @Override - public QueryTokenStream visitSearchedCaseExpression(HqlParser.SearchedCaseExpressionContext ctx) { + public QueryTokenStream visitJpaNonstandardFunction(HqlParser.JpaNonstandardFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.CASE())); + builder.append(QueryTokens.token(ctx.FUNCTION())); + builder.append(TOKEN_OPEN_PAREN); - builder.append(QueryTokenStream.concat(ctx.caseWhenPredicateClause(), this::visit, TOKEN_SPACE)); + QueryRendererBuilder nested = QueryRenderer.builder(); + nested.appendInline(visit(ctx.jpaNonstandardFunctionName())); - if (ctx.ELSE() != null) { + if (ctx.castTarget() != null) { + nested.append(QueryTokens.expression(ctx.AS())); + nested.append(visit(ctx.castTarget())); + } - builder.append(QueryTokens.expression(ctx.ELSE())); - builder.appendExpression(visit(ctx.expressionOrPredicate())); + if (ctx.genericFunctionArguments() != null) { + nested.append(TOKEN_COMMA); + nested.appendInline(visit(ctx.genericFunctionArguments())); } - builder.append(QueryTokens.expression(ctx.END())); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); return builder; } @Override - public QueryTokenStream visitCaseWhenExpressionClause(HqlParser.CaseWhenExpressionClauseContext ctx) { + public QueryTokenStream visitColumnFunction(HqlParser.ColumnFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.WHEN())); - builder.appendExpression(visit(ctx.expression())); - builder.append(QueryTokens.expression(ctx.THEN())); - builder.appendExpression(visit(ctx.expressionOrPredicate())); - - return builder; - } + builder.append(QueryTokens.token(ctx.COLUMN())); + builder.append(TOKEN_OPEN_PAREN); - @Override - public QueryTokenStream visitCaseWhenPredicateClause(HqlParser.CaseWhenPredicateClauseContext ctx) { + QueryRendererBuilder nested = QueryRenderer.builder(); + nested.appendInline(visit(ctx.path())); + nested.append(TOKEN_DOT); + nested.appendExpression(visit(ctx.jpaNonstandardFunctionName())); - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.castTarget() != null) { + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.jpaNonstandardFunctionName())); + } - builder.append(QueryTokens.expression(ctx.WHEN())); - builder.appendExpression(visit(ctx.predicate())); - builder.append(QueryTokens.expression(ctx.THEN())); - builder.appendExpression(visit(ctx.expressionOrPredicate())); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); return builder; } @Override - public QueryTokenStream visitGenericFunction(HqlParser.GenericFunctionContext ctx) { + public QueryTokenStream visitGenericFunctionArguments(HqlParser.GenericFunctionArgumentsContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - QueryRendererBuilder nested = QueryRenderer.builder(); - - nested.append(visit(ctx.genericFunctionName())); - nested.append(TOKEN_OPEN_PAREN); - - if (ctx.genericFunctionArguments() != null) { - nested.appendInline(visit(ctx.genericFunctionArguments())); - } else if (ctx.ASTERISK() != null) { - nested.append(QueryTokens.token(ctx.ASTERISK())); - } - - nested.append(TOKEN_CLOSE_PAREN); - builder.append(nested); - if (ctx.pathContinuation() != null) { - builder.append(visit(ctx.pathContinuation())); - } - - if (ctx.nthSideClause() != null) { - builder.appendExpression(visit(ctx.nthSideClause())); - } - - if (ctx.nullsClause() != null) { - builder.appendExpression(visit(ctx.nullsClause())); - } - - if (ctx.withinGroupClause() != null) { - builder.appendExpression(visit(ctx.withinGroupClause())); + if (ctx.DISTINCT() != null) { + builder.append(QueryTokens.expression(ctx.DISTINCT())); } - if (ctx.filterClause() != null) { - builder.appendExpression(visit(ctx.filterClause())); + if (ctx.datetimeField() != null) { + builder.append(visit(ctx.datetimeField())); + builder.append(TOKEN_COMMA); } - if (ctx.overClause() != null) { - builder.appendExpression(visit(ctx.overClause())); - } + builder.append(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); return builder; } @Override - public QueryTokenStream visitFilterClause(HqlParser.FilterClauseContext ctx) { + public QueryTokenStream visitCollectionSizeFunction(HqlParser.CollectionSizeFunctionContext ctx) { + return QueryTokenStream.ofFunction(ctx.SIZE(), visit(ctx.path())); + } + + @Override + public QueryTokenStream visitElementAggregateFunction(HqlParser.ElementAggregateFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.FILTER())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.whereClause())); - builder.append(TOKEN_CLOSE_PAREN); + 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 { + + 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())); + } + + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.elementsValuesQuantifier())); + builder.append(TOKEN_OPEN_PAREN); + + if (ctx.path() != null) { + builder.append(visit(ctx.path())); + } + + builder.append(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_CLOSE_PAREN); + } return builder; } @Override - public QueryTokenStream visitOverClause(HqlParser.OverClauseContext ctx) { + public QueryTokenStream visitIndexAggregateFunction(HqlParser.IndexAggregateFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.OVER())); - QueryRendererBuilder nested = QueryRenderer.builder(); - nested.append(TOKEN_OPEN_PAREN); + 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 { - List trees = new ArrayList<>(); + 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())); + } - if (ctx.partitionClause() != null) { - trees.add(ctx.partitionClause()); - } + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.indicesKeysQuantifier())); + builder.append(TOKEN_OPEN_PAREN); - if (ctx.orderByClause() != null) { - trees.add(ctx.orderByClause()); - } + if (ctx.path() != null) { + builder.append(visit(ctx.path())); + } - if (ctx.frameClause() != null) { - trees.add(ctx.frameClause()); + builder.append(TOKEN_CLOSE_PAREN); + builder.append(TOKEN_CLOSE_PAREN); } - nested.appendInline(QueryTokenStream.concat(trees, this::visit, TOKEN_SPACE)); - nested.append(TOKEN_CLOSE_PAREN); - - builder.appendInline(nested); - return builder; } @Override - public QueryTokenStream visitPartitionClause(HqlParser.PartitionClauseContext ctx) { + public QueryTokenStream visitCollectionFunctionMisuse(HqlParser.CollectionFunctionMisuseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.PARTITION())); - builder.append(QueryTokens.expression(ctx.BY())); - - builder.append(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); + 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); return builder; } @Override - public QueryTokenStream visitFrameClause(HqlParser.FrameClauseContext ctx) { + public QueryTokenStream visitListaggFunction(HqlParser.ListaggFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.RANGE() != null) { - builder.append(QueryTokens.expression(ctx.RANGE())); - } else if (ctx.ROWS() != null) { - builder.append(QueryTokens.expression(ctx.ROWS())); - } else if (ctx.GROUPS() != null) { - builder.append(QueryTokens.expression(ctx.GROUPS())); + builder.append(QueryTokens.token(ctx.LISTAGG())); + builder.append(TOKEN_OPEN_PAREN); + + QueryRendererBuilder nested = QueryRenderer.builder(); + + if (ctx.DISTINCT() != null) { + builder.append(QueryTokens.expression(ctx.DISTINCT())); } - if (ctx.BETWEEN() != null) { - builder.append(QueryTokens.expression(ctx.BETWEEN())); + builder.appendInline(visit(ctx.expressionOrPredicate(0))); + builder.append(TOKEN_COMMA); + builder.appendInline(visit(ctx.expressionOrPredicate(1))); + + if (ctx.onOverflowClause() != null) { + builder.appendExpression(visit(ctx.onOverflowClause())); } - builder.appendExpression(visit(ctx.frameStart())); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); - if (ctx.AND() != null) { + if (ctx.withinGroupClause() != null) { + builder.appendExpression(visit(ctx.withinGroupClause())); + } - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.frameEnd())); + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); } - if (ctx.frameExclusion() != null) { - builder.appendExpression(visit(ctx.frameExclusion())); + if (ctx.overClause() != null) { + builder.appendExpression(visit(ctx.overClause())); } return builder; } @Override - public QueryTokenStream visitCastFunction(HqlParser.CastFunctionContext ctx) { + public QueryTokenStream visitWithinGroupClause(HqlParser.WithinGroupClauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.CAST())); + builder.append(QueryTokens.expression(ctx.WITHIN())); + builder.append(QueryTokens.expression(ctx.GROUP())); builder.append(TOKEN_OPEN_PAREN); - - QueryRendererBuilder nested = QueryRenderer.builder(); - nested.appendExpression(visit(ctx.expression())); - nested.append(QueryTokens.expression(ctx.AS())); - nested.appendExpression(visit(ctx.castTarget())); - - builder.appendInline(nested); + builder.appendInline(visit(ctx.orderByClause())); builder.append(TOKEN_CLOSE_PAREN); return builder; } @Override - public QueryTokenStream visitCastTarget(HqlParser.CastTargetContext ctx) { + public QueryTokenStream visitJsonArrayFunction(HqlParser.JsonArrayFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(visit(ctx.castTargetType())); + builder.appendExpression(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); - if (ctx.INTEGER_LITERAL() != null && !ctx.INTEGER_LITERAL().isEmpty()) { + if (ctx.jsonNullClause() != null) { + builder.appendExpression(visit(ctx.jsonNullClause())); + } - builder.append(TOKEN_OPEN_PAREN); + return QueryTokenStream.ofFunction(ctx.JSON_ARRAY(), builder); + } - List tokens = new ArrayList<>(); - ctx.INTEGER_LITERAL().forEach(terminalNode -> { + @Override + public QueryTokenStream visitJsonExistsFunction(HqlParser.JsonExistsFunctionContext ctx) { - if (!tokens.isEmpty()) { - tokens.add(TOKEN_COMMA); - } - tokens.add(QueryTokens.expression(terminalNode)); + QueryRendererBuilder builder = QueryRenderer.builder(); - }); + builder.appendExpression(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - builder.append(tokens); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.jsonPassingClause() != null) { + builder.appendExpression(visit(ctx.jsonPassingClause())); } - return builder; - } + if (ctx.jsonExistsOnErrorClause() != null) { + builder.appendExpression(visit(ctx.jsonExistsOnErrorClause())); + } - @Override - public QueryTokenStream visitCastTargetType(HqlParser.CastTargetTypeContext ctx) { - return QueryRendererBuilder.from(QueryTokens.expression(ctx.fullTargetName)); + return QueryTokenStream.ofFunction(ctx.JSON_EXISTS(), builder); } @Override - public QueryTokenStream visitExtractFunction(HqlParser.ExtractFunctionContext ctx) { + public QueryTokenStream visitJsonObjectFunction(HqlParser.JsonObjectFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.EXTRACT() != null) { + builder.appendExpression(QueryTokenStream.concat(ctx.jsonObjectFunctionEntry(), this::visit, TOKEN_COMMA)); + + if (ctx.jsonNullClause() != null) { + builder.appendExpression(visit(ctx.jsonNullClause())); + } + + return QueryTokenStream.ofFunction(ctx.JSON_OBJECT(), builder); + } - builder.append(QueryTokens.token(ctx.EXTRACT())); - builder.append(TOKEN_OPEN_PAREN); + @Override + public QueryTokenStream visitJsonQueryFunction(HqlParser.JsonQueryFunctionContext ctx) { - QueryRendererBuilder nested = QueryRenderer.builder(); + QueryRendererBuilder builder = QueryRenderer.builder(); - nested.appendExpression(visit(ctx.extractField())); - nested.append(QueryTokens.expression(ctx.FROM())); - nested.append(visit(ctx.expression())); + builder.appendExpression(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - builder.appendInline(nested); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.datetimeField() != null) { + if (ctx.jsonPassingClause() != null) { + builder.appendExpression(visit(ctx.jsonPassingClause())); + } - builder.append(visit(ctx.datetimeField())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.expression())); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.jsonQueryWrapperClause() != null) { + builder.appendExpression(visit(ctx.jsonQueryWrapperClause())); } - return builder; + builder.append(QueryTokenStream.concat(ctx.jsonQueryOnErrorOrEmptyClause(), this::visit, TOKEN_SPACE)); + + return QueryTokenStream.ofFunction(ctx.JSON_QUERY(), builder); } @Override - public QueryTokenStream visitExtractField(HqlParser.ExtractFieldContext ctx) { + public QueryTokenStream visitJsonValueFunction(HqlParser.JsonValueFunctionContext ctx) { - if (ctx.datetimeField() != null) { - return visit(ctx.datetimeField()); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.dayField() != null) { - return visit(ctx.dayField()); - } + builder.append(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - if (ctx.weekField() != null) { - return visit(ctx.weekField()); + if (ctx.jsonPassingClause() != null) { + builder.appendExpression(visit(ctx.jsonPassingClause())); } - if (ctx.timeZoneField() != null) { - return visit(ctx.timeZoneField()); + if (ctx.jsonValueReturningClause() != null) { + builder.appendExpression(visit(ctx.jsonValueReturningClause())); } - if (ctx.dateOrTimeField() != null) { - return visit(ctx.dateOrTimeField()); - } + builder.append(QueryTokenStream.concat(ctx.jsonValueOnErrorOrEmptyClause(), this::visit, TOKEN_SPACE)); - return QueryRenderer.builder(); + return QueryTokenStream.ofFunction(ctx.JSON_VALUE(), builder); } @Override - public QueryTokenStream visitTrimFunction(HqlParser.TrimFunctionContext ctx) { + public QueryTokenStream visitJsonArrayAggFunction(HqlParser.JsonArrayAggFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.TRIM())); - builder.append(TOKEN_OPEN_PAREN); + builder.appendExpression(visit(ctx.expressionOrPredicate())); - if (ctx.trimSpecification() != null) { - builder.appendExpression(visit(ctx.trimSpecification())); + if (ctx.jsonNullClause() != null) { + builder.appendExpression(visit(ctx.jsonNullClause())); } - if (ctx.trimCharacter() != null) { - builder.appendExpression(visit(ctx.trimCharacter())); + if (ctx.orderByClause() != null) { + builder.appendExpression(visit(ctx.orderByClause())); } - if (ctx.FROM() != null) { - builder.append(QueryTokens.expression(ctx.FROM())); - } + QueryTokenStream function = QueryTokenStream.ofFunction(ctx.JSON_ARRAYAGG(), builder); - if (ctx.expression() != null) { - builder.append(visit(ctx.expression())); + if (ctx.filterClause() == null) { + return function; } - builder.append(TOKEN_CLOSE_PAREN); + QueryRendererBuilder functionWithFilter = QueryRenderer.builder(); + functionWithFilter.appendExpression(function); + functionWithFilter.appendExpression(visit(ctx.filterClause())); - return builder; + return functionWithFilter.build(); } @Override - public QueryTokenStream visitTrimSpecification(HqlParser.TrimSpecificationContext ctx) { + public QueryTokenStream visitJsonObjectAggFunction(HqlParser.JsonObjectAggFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.BOTH() != null) { - builder.append(QueryTokens.expression(ctx.BOTH())); - } else if (ctx.LEADING() != null) { - builder.append(QueryTokens.expression(ctx.LEADING())); - } else if (ctx.TRAILING() != null) { - builder.append(QueryTokens.expression(ctx.TRAILING())); + if (ctx.KEY() != null) { + builder.append(QueryTokens.expression(ctx.KEY())); } - return builder.build(); - } + builder.appendExpression(visit(ctx.expressionOrPredicate(0))); - @Override - public QueryTokenStream visitTrimCharacter(HqlParser.TrimCharacterContext ctx) { + if (ctx.VALUE() != null) { + builder.append(QueryTokens.expression(ctx.VALUE())); + } else { + builder.append(TOKEN_COLON); + } + + builder.appendExpression(visit(ctx.expressionOrPredicate(1))); + + if (ctx.jsonNullClause() != null) { + builder.appendExpression(visit(ctx.jsonNullClause())); + } - if (ctx.STRING_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.STRING_LITERAL())); + if (ctx.jsonUniqueKeysClause() != null) { + builder.appendExpression(visit(ctx.jsonUniqueKeysClause())); } - return visit(ctx.parameter()); + QueryTokenStream function = QueryTokenStream.ofFunction(ctx.JSON_OBJECTAGG(), builder); + + if (ctx.filterClause() == null) { + return function; + } + + QueryRendererBuilder functionWithFilter = QueryRenderer.builder(); + functionWithFilter.appendExpression(function); + functionWithFilter.appendExpression(visit(ctx.filterClause())); + + return functionWithFilter.build(); } @Override - public QueryTokenStream visitEveryFunction(HqlParser.EveryFunctionContext ctx) { + public QueryTokenStream visitJsonPassingClause(HqlParser.JsonPassingClauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.everyAllQuantifier())); + builder.append(QueryTokens.expression(ctx.PASSING())); + builder.append(QueryTokenStream.concat(ctx.aliasedExpressionOrPredicate(), this::visit, TOKEN_COMMA)); - if (ctx.predicate() != null) { - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.predicate())); - builder.append(TOKEN_CLOSE_PAREN); + return builder; + } - if (ctx.filterClause() != null) { - builder.appendExpression(visit(ctx.filterClause())); - } + @Override + public QueryTokenStream visitJsonTableFunction(HqlParser.JsonTableFunctionContext ctx) { - 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 { + QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.collectionQuantifier())); + builder.appendExpression(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.simplePath())); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.jsonPassingClause() != null) { + builder.appendExpression(visit(ctx.jsonPassingClause())); } - return builder; + builder.appendExpression(visit(ctx.jsonTableColumnsClause())); + + if (ctx.jsonTableErrorClause() != null) { + builder.appendExpression(visit(ctx.jsonTableErrorClause())); + } + + return QueryTokenStream.ofFunction(ctx.JSON_TABLE(), builder); } @Override - public QueryTokenStream visitAnyFunction(HqlParser.AnyFunctionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + public QueryTokenStream visitJsonTableColumnsClause(HqlParser.JsonTableColumnsClauseContext ctx) { + return QueryTokenStream.ofFunction(ctx.COLUMNS(), visit(ctx.jsonTableColumns())); + } - builder.appendExpression(visit(ctx.anySomeQuantifier())); + @Override + public QueryTokenStream visitJsonTableColumns(HqlParser.JsonTableColumnsContext ctx) { + return QueryTokenStream.concat(ctx.jsonTableColumn(), this::visit, TOKEN_COMMA); + } - if (ctx.predicate() != null) { - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.predicate())); - builder.append(TOKEN_CLOSE_PAREN); + @Override + public QueryTokenStream visitXmlElementFunction(HqlParser.XmlElementFunctionContext ctx) { - if (ctx.filterClause() != null) { - builder.appendExpression(visit(ctx.filterClause())); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - 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(QueryTokens.expression(ctx.NAME())); + builder.append(visit(ctx.identifier())); - builder.appendExpression(visit(ctx.collectionQuantifier())); + if (ctx.xmlAttributesFunction() != null) { + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.xmlAttributesFunction())); + } - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.simplePath())); - builder.append(TOKEN_CLOSE_PAREN); + if (!CollectionUtils.isEmpty(ctx.expressionOrPredicate())) { + builder.append(TOKEN_COMMA); + builder.append(QueryTokenStream.concat(ctx.expressionOrPredicate(), this::visit, TOKEN_COMMA)); } - return builder; + return QueryTokenStream.ofFunction(ctx.XMLELEMENT(), builder); } @Override - public QueryTokenStream visitTreatedNavigablePath(HqlParser.TreatedNavigablePathContext ctx) { + public QueryTokenStream visitXmlAttributesFunction(HqlParser.XmlAttributesFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.TREAT())); - builder.append(TOKEN_OPEN_PAREN); + builder.appendExpression(QueryTokenStream.concat(ctx.aliasedExpressionOrPredicate(), this::visit, TOKEN_COMMA)); - QueryRendererBuilder nested = QueryRenderer.builder(); - nested.appendExpression(visit(ctx.path())); - nested.append(QueryTokens.expression(ctx.AS())); - nested.append(visit(ctx.simplePath())); + return QueryTokenStream.ofFunction(ctx.XMLATTRIBUTES(), builder); + } - builder.appendInline(nested); - builder.append(TOKEN_CLOSE_PAREN); + @Override + public QueryTokenStream visitXmlForestFunction(HqlParser.XmlForestFunctionContext ctx) { - if (ctx.pathContinuation() != null) { - builder.append(visit(ctx.pathContinuation())); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - return builder; + builder.appendExpression( + QueryTokenStream.concat(ctx.potentiallyAliasedExpressionOrPredicate(), this::visit, TOKEN_COMMA)); + + return QueryTokenStream.ofFunction(ctx.XMLFOREST(), builder); } @Override - public QueryTokenStream visitCollectionValueNavigablePath(HqlParser.CollectionValueNavigablePathContext ctx) { + public QueryTokenStream visitXmlPiFunction(HqlParser.XmlPiFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(visit(ctx.elementValueQuantifier())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.path())); - builder.append(TOKEN_CLOSE_PAREN); + builder.append(QueryTokens.expression(ctx.NAME())); + builder.append(visit(ctx.identifier())); - if (ctx.pathContinuation() != null) { - builder.append(visit(ctx.pathContinuation())); + if (ctx.expression() != null) { + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.expression())); } - return builder; + return QueryTokenStream.ofFunction(ctx.XMLPI(), builder); } @Override - public QueryTokenStream visitMapKeyNavigablePath(HqlParser.MapKeyNavigablePathContext ctx) { + public QueryTokenStream visitXmlQueryFunction(HqlParser.XmlQueryFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(visit(ctx.indexKeyQuantifier())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.path())); - builder.append(TOKEN_CLOSE_PAREN); - - if (ctx.pathContinuation() != null) { - builder.append(visit(ctx.pathContinuation())); - } + builder.appendExpression(visit(ctx.expression(0))); + builder.append(QueryTokens.expression(ctx.PASSING())); + builder.appendExpression(visit(ctx.expression(1))); - return builder; + return QueryTokenStream.ofFunction(ctx.XMLQUERY(), builder); } @Override - public QueryTokenStream visitToOneFkReference(HqlParser.ToOneFkReferenceContext ctx) { + public QueryTokenStream visitXmlExistsFunction(HqlParser.XmlExistsFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.FK())); - builder.append(TOKEN_OPEN_PAREN); - builder.append(visit(ctx.path())); - builder.append(TOKEN_CLOSE_PAREN); + builder.appendExpression(visit(ctx.expression(0))); + builder.append(QueryTokens.expression(ctx.PASSING())); + builder.appendExpression(visit(ctx.expression(1))); - return builder; + return QueryTokenStream.ofFunction(ctx.XMLEXISTS(), builder); } @Override - public QueryTokenStream visitElementValueQuantifier(HqlParser.ElementValueQuantifierContext ctx) { + public QueryTokenStream visitXmlAggFunction(HqlParser.XmlAggFunctionContext ctx) { + + QueryRendererBuilder args = QueryRenderer.builder(); - if (ctx.ELEMENT() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.ELEMENT())); + args.appendExpression(visit(ctx.expression())); + if (ctx.orderByClause() != null) { + args.appendExpression(visit(ctx.orderByClause())); } - if (ctx.VALUE() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.VALUE())); + QueryTokenStream function = QueryTokenStream.ofFunction(ctx.XMLAGG(), args); + + if (ctx.filterClause() == null && ctx.overClause() == null) { + return function; + } + + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.appendExpression(function); + + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); + } + + if (ctx.overClause() != null) { + builder.appendExpression(visit(ctx.overClause())); } - return QueryTokenStream.empty(); + return builder; } @Override - public QueryTokenStream visitIndexKeyQuantifier(HqlParser.IndexKeyQuantifierContext ctx) { + public QueryTokenStream visitXmlTableFunction(HqlParser.XmlTableFunctionContext ctx) { - if (ctx.INDEX() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INDEX())); - } + QueryRendererBuilder args = QueryRenderer.builder(); - if (ctx.KEY() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.KEY())); - } + args.appendExpression(visit(ctx.expression(0))); + args.append(QueryTokens.expression(ctx.PASSING())); + args.appendExpression(visit(ctx.expression(1))); + args.appendExpression(visit(ctx.xmlTableColumnsClause())); - return QueryTokenStream.empty(); + return QueryTokenStream.ofFunction(ctx.XMLTABLE(), args); } @Override - public QueryTokenStream visitIsBooleanPredicate(HqlParser.IsBooleanPredicateContext ctx) { + public QueryTokenStream visitXmlTableColumnsClause(HqlParser.XmlTableColumnsClauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.expression())); - builder.append(QueryTokens.expression(ctx.IS())); + builder.append(QueryTokens.expression(ctx.COLUMNS())); + builder.append(QueryTokenStream.concat(ctx.xmlTableColumn(), this::visit, TOKEN_COMMA)); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } + return builder; + } - if (ctx.NULL() != null) { - builder.append(QueryTokens.expression(ctx.NULL())); - } + @Override + public QueryTokenStream visitPath(HqlParser.PathContext ctx) { + return QueryTokenStream.concat(ctx.children, this::visit, EMPTY_TOKEN); + } - if (ctx.TRUE() != null) { - builder.append(QueryTokens.expression(ctx.TRUE())); - } + @Override + public QueryTokenStream visitGeneralPathFragment(HqlParser.GeneralPathFragmentContext ctx) { + return QueryTokenStream.concat(ctx.children, this::visit, EMPTY_TOKEN); + } - if (ctx.FALSE() != null) { - builder.append(QueryTokens.expression(ctx.FALSE())); - } + @Override + public QueryTokenStream visitIndexedPathAccessFragment(HqlParser.IndexedPathAccessFragmentContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(TOKEN_OPEN_SQUARE_BRACKET); + builder.appendInline(visit(ctx.expression())); + builder.append(TOKEN_CLOSE_SQUARE_BRACKET); - if (ctx.EMPTY() != null) { - builder.append(QueryTokens.expression(ctx.EMPTY())); + if (ctx.generalPathFragment() != null) { + + builder.append(TOKEN_DOT); + builder.append(visit(ctx.generalPathFragment())); } return builder; } @Override - public QueryTokenStream visitMemberOfPredicate(HqlParser.MemberOfPredicateContext ctx) { + public QueryTokenStream visitSimplePath(HqlParser.SimplePathContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.expression())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - if (ctx.MEMBER() != null) { - builder.append(QueryTokens.expression(ctx.MEMBER())); - } - if (ctx.OF() != null) { - builder.append(QueryTokens.expression(ctx.OF())); + builder.append(visit(ctx.identifier())); + + if (!ctx.simplePathElement().isEmpty()) { + builder.append(TOKEN_DOT); } - builder.append(visit(ctx.path())); + builder.append(QueryTokenStream.concat(ctx.simplePathElement(), this::visit, TOKEN_DOT)); return builder; } @Override - public QueryTokenStream visitIsDistinctFromPredicate(HqlParser.IsDistinctFromPredicateContext ctx) { + public QueryTokenStream visitSimplePathElement(HqlParser.SimplePathElementContext ctx) { + return visit(ctx.identifier()); + } + + @Override + public QueryTokenStream visitGenericFunction(HqlParser.GenericFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.expression(0))); - builder.append(QueryTokens.expression(ctx.IS())); + nested.append(visit(ctx.genericFunctionName())); + nested.append(TOKEN_OPEN_PAREN); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); + if (ctx.genericFunctionArguments() != null) { + nested.appendInline(visit(ctx.genericFunctionArguments())); + } else if (ctx.ASTERISK() != null) { + nested.append(QueryTokens.token(ctx.ASTERISK())); } - if (ctx.DISTINCT() != null) { + nested.append(TOKEN_CLOSE_PAREN); + builder.append(nested); - builder.append(QueryTokens.expression(ctx.DISTINCT())); - builder.append(QueryTokens.expression(ctx.FROM())); - builder.appendExpression(visit(ctx.expression(1))); + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); + } + + if (ctx.nthSideClause() != null) { + builder.appendExpression(visit(ctx.nthSideClause())); + } + + if (ctx.nullsClause() != null) { + builder.appendExpression(visit(ctx.nullsClause())); + } + + if (ctx.withinGroupClause() != null) { + builder.appendExpression(visit(ctx.withinGroupClause())); + } + + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); + } + + if (ctx.overClause() != null) { + builder.appendExpression(visit(ctx.overClause())); } return builder; } @Override - public QueryTokenStream visitBetweenPredicate(HqlParser.BetweenPredicateContext ctx) { - return visit(ctx.betweenExpression()); + public QueryTokenStream visitFilterClause(HqlParser.FilterClauseContext ctx) { + + QueryRendererBuilder builder = QueryRenderer.builder(); + + builder.append(QueryTokens.expression(ctx.FILTER())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.whereClause())); + builder.append(TOKEN_CLOSE_PAREN); + + return builder; } @Override - public QueryTokenStream visitContainsPredicate(HqlParser.ContainsPredicateContext ctx) { + public QueryTokenStream visitOverClause(HqlParser.OverClauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(QueryTokens.expression(ctx.OVER())); - builder.appendExpression(visit(ctx.expression(0))); + QueryRendererBuilder nested = QueryRenderer.builder(); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } + List trees = new ArrayList<>(); - if (ctx.CONTAINS() != null) { - builder.append(QueryTokens.expression(ctx.CONTAINS())); + if (ctx.partitionClause() != null) { + trees.add(ctx.partitionClause()); } - if (ctx.INCLUDES() != null) { - builder.append(QueryTokens.expression(ctx.INCLUDES())); + + if (ctx.orderByClause() != null) { + trees.add(ctx.orderByClause()); } - if (ctx.INTERSECTS() != null) { - builder.append(QueryTokens.expression(ctx.INTERSECTS())); + + if (ctx.frameClause() != null) { + trees.add(ctx.frameClause()); } - builder.appendExpression(visit(ctx.expression(1))); + nested.appendInline(QueryTokenStream.concat(trees, this::visit, TOKEN_SPACE)); + builder.appendInline(QueryTokenStream.group(nested)); return builder; - } @Override - public QueryTokenStream visitOrPredicate(HqlParser.OrPredicateContext ctx) { + public QueryTokenStream visitPartitionClause(HqlParser.PartitionClauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.predicate(0))); - builder.append(QueryTokens.expression(ctx.OR())); - builder.appendExpression(visit(ctx.predicate(1))); + builder.append(QueryTokens.expression(ctx.PARTITION())); + builder.append(QueryTokens.expression(ctx.BY())); + + builder.append(QueryTokenStream.concat(ctx.expression(), this::visit, TOKEN_COMMA)); return builder; } @Override - public QueryTokenStream visitRelationalPredicate(HqlParser.RelationalPredicateContext ctx) { - return visit(ctx.relationalExpression()); - } + public QueryTokenStream visitCastFunction(HqlParser.CastFunctionContext ctx) { - @Override - public QueryTokenStream visitExistsPredicate(HqlParser.ExistsPredicateContext ctx) { - return visit(ctx.existsExpression()); + QueryRendererBuilder nested = QueryRenderer.builder(); + nested.appendExpression(visit(ctx.expression())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.castTarget())); + + return QueryTokenStream.ofFunction(ctx.CAST(), nested); } @Override - public QueryTokenStream visitAndPredicate(HqlParser.AndPredicateContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + public QueryTokenStream visitCastTarget(HqlParser.CastTargetContext ctx) { - builder.appendExpression(visit(ctx.predicate(0))); - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.predicate(1))); + List literals = ctx.INTEGER_LITERAL(); - return builder; - } + if (!CollectionUtils.isEmpty(literals)) { - @Override - public QueryTokenStream visitGroupedPredicate(HqlParser.GroupedPredicateContext ctx) { + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.append(visit(ctx.castTargetType())); + builder.append(TOKEN_OPEN_PAREN); - QueryRendererBuilder builder = QueryRenderer.builder(); + 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))); + } - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.predicate())); - builder.append(TOKEN_CLOSE_PAREN); + builder.appendInline(args.build()); + builder.append(TOKEN_CLOSE_PAREN); - return builder; - } + return builder.build(); + } - @Override - public QueryTokenStream visitLikePredicate(HqlParser.LikePredicateContext ctx) { - return visit(ctx.stringPatternMatching()); + return visit(ctx.castTargetType()); } @Override - public QueryTokenStream visitInPredicate(HqlParser.InPredicateContext ctx) { - return visit(ctx.inExpression()); + public QueryTokenStream visitCastTargetType(HqlParser.CastTargetTypeContext ctx) { + return QueryTokens.token(ctx.fullTargetName); } @Override - public QueryTokenStream visitNotPredicate(HqlParser.NotPredicateContext ctx) { + public QueryTokenStream visitExtractFunction(HqlParser.ExtractFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(TOKEN_NOT); - builder.append(visit(ctx.predicate())); + if (ctx.EXTRACT() != null) { + + builder.append(QueryTokens.token(ctx.EXTRACT())); + builder.append(TOKEN_OPEN_PAREN); + + QueryRendererBuilder nested = QueryRenderer.builder(); + + nested.appendExpression(visit(ctx.extractField())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.expression())); + + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); + } else if (ctx.datetimeField() != null) { + + builder.append(visit(ctx.datetimeField())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.expression())); + builder.append(TOKEN_CLOSE_PAREN); + } return builder; } @Override - public QueryTokenStream visitExpressionPredicate(HqlParser.ExpressionPredicateContext ctx) { - return visit(ctx.expression()); - } + public QueryTokenStream visitTrimFunction(HqlParser.TrimFunctionContext ctx) { - @Override - public QueryTokenStream visitExpressionOrPredicate(HqlParser.ExpressionOrPredicateContext ctx) { + QueryRendererBuilder builder = QueryRenderer.builder(); + + if (ctx.trimSpecification() != null) { + builder.appendExpression(visit(ctx.trimSpecification())); + } + + if (ctx.trimCharacter() != null) { + builder.appendExpression(visit(ctx.trimCharacter())); + } + + 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 QueryTokenStream.empty(); + builder.append(visit(ctx.expression())); } + + return QueryTokenStream.ofFunction(ctx.TRIM(), builder); } @Override - public QueryTokenStream visitRelationalExpression(HqlParser.RelationalExpressionContext ctx) { + public QueryTokenStream visitEveryFunction(HqlParser.EveryFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendInline(visit(ctx.expression(0))); - builder.append(QueryTokens.ventilated(ctx.op)); - builder.appendInline(visit(ctx.expression(1))); + builder.appendExpression(visit(ctx.everyAllQuantifier())); + + if (ctx.predicate() != null) { + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.predicate())); + builder.append(TOKEN_CLOSE_PAREN); + + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); + } + + 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 builder; } @Override - public QueryTokenStream visitBetweenExpression(HqlParser.BetweenExpressionContext ctx) { + public QueryTokenStream visitAnyFunction(HqlParser.AnyFunctionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.expression(0))); + builder.appendExpression(visit(ctx.anySomeQuantifier())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } + if (ctx.predicate() != null) { + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(visit(ctx.predicate())); + builder.append(TOKEN_CLOSE_PAREN); - builder.append(QueryTokens.expression(ctx.BETWEEN())); - builder.appendExpression(visit(ctx.expression(1))); - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.expression(2))); + if (ctx.filterClause() != null) { + builder.appendExpression(visit(ctx.filterClause())); + } + + 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 builder; } @Override - public QueryTokenStream visitStringPatternMatching(HqlParser.StringPatternMatchingContext ctx) { + public QueryTokenStream visitTreatedNavigablePath(HqlParser.TreatedNavigablePathContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.expression(0))); + nested.appendExpression(visit(ctx.path())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.append(visit(ctx.simplePath())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } + builder.append(QueryTokenStream.ofFunction(ctx.TREAT(), nested)); - if (ctx.LIKE() != null) { - builder.append(QueryTokens.expression(ctx.LIKE())); - } else if (ctx.ILIKE() != null) { - builder.append(QueryTokens.expression(ctx.ILIKE())); + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); } - builder.appendExpression(visit(ctx.expression(1))); + return builder; + } + + @Override + public QueryTokenStream visitCollectionValueNavigablePath(HqlParser.CollectionValueNavigablePathContext ctx) { - if (ctx.ESCAPE() != null) { + QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.ESCAPE())); + builder.append(visit(ctx.elementValueQuantifier())); + builder.append(TOKEN_OPEN_PAREN); + builder.append(visit(ctx.path())); + builder.append(TOKEN_CLOSE_PAREN); - if (ctx.STRING_LITERAL() != null) { - builder.append(QueryTokens.expression(ctx.STRING_LITERAL())); - } else if (ctx.JAVA_STRING_LITERAL() != null) { - builder.append(QueryTokens.expression(ctx.JAVA_STRING_LITERAL())); - } else if (ctx.parameter() != null) { - builder.appendExpression(visit(ctx.parameter())); - } + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); } return builder; } @Override - public QueryTokenStream visitInExpression(HqlParser.InExpressionContext ctx) { + public QueryTokenStream visitMapKeyNavigablePath(HqlParser.MapKeyNavigablePathContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(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) { - builder.append(QueryTokens.expression(ctx.NOT())); + if (ctx.pathContinuation() != null) { + builder.append(visit(ctx.pathContinuation())); } - builder.append(QueryTokens.expression(ctx.IN())); - builder.appendExpression(visit(ctx.inList())); - return builder; } + @Override + public QueryTokenStream visitToOneFkReference(HqlParser.ToOneFkReferenceContext ctx) { + return QueryTokenStream.ofFunction(ctx.FK(), visit(ctx.path())); + } + + @Override + public QueryTokenStream visitGroupedPredicate(HqlParser.GroupedPredicateContext ctx) { + return QueryTokenStream.group(visit(ctx.predicate())); + } + @Override public QueryTokenStream visitInList(HqlParser.InListContext ctx) { @@ -3804,9 +2019,9 @@ public QueryTokenStream visitInList(HqlParser.InListContext ctx) { if (ctx.simplePath() != null) { if (ctx.ELEMENTS() != null) { - builder.append(QueryTokens.expression(ctx.ELEMENTS())); + builder.append(QueryTokens.token(ctx.ELEMENTS())); } else if (ctx.INDICES() != null) { - builder.append(QueryTokens.expression(ctx.INDICES())); + builder.append(QueryTokens.token(ctx.INDICES())); } builder.append(TOKEN_OPEN_PAREN); @@ -3839,9 +2054,9 @@ public QueryTokenStream visitExistsExpression(HqlParser.ExistsExpressionContext builder.append(QueryTokens.expression(ctx.EXISTS())); if (ctx.ELEMENTS() != null) { - builder.append(QueryTokens.expression(ctx.ELEMENTS())); + builder.append(QueryTokens.token(ctx.ELEMENTS())); } else if (ctx.INDICES() != null) { - builder.append(QueryTokens.expression(ctx.INDICES())); + builder.append(QueryTokens.token(ctx.INDICES())); } builder.append(TOKEN_OPEN_PAREN); @@ -3857,84 +2072,11 @@ public QueryTokenStream visitExistsExpression(HqlParser.ExistsExpressionContext return builder; } - @Override - public QueryTokenStream visitInstantiationTarget(HqlParser.InstantiationTargetContext ctx) { - - if (ctx.LIST() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.LIST())); - } else if (ctx.MAP() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.MAP())); - } else if (ctx.simplePath() != null) { - - return visit(ctx.simplePath()); - } else { - return QueryTokenStream.empty(); - } - } - @Override public QueryTokenStream visitInstantiationArguments(HqlParser.InstantiationArgumentsContext ctx) { return QueryTokenStream.concat(ctx.instantiationArgument(), this::visit, TOKEN_COMMA); } - @Override - public QueryTokenStream visitInstantiationArgument(HqlParser.InstantiationArgumentContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.expressionOrPredicate() != null) { - builder.appendExpression(visit(ctx.expressionOrPredicate())); - } else if (ctx.instantiation() != null) { - builder.appendExpression(visit(ctx.instantiation())); - } - - if (ctx.variable() != null) { - builder.append(visit(ctx.variable())); - } - - return builder; - } - - @Override - public QueryTokenStream visitParameterOrIntegerLiteral(HqlParser.ParameterOrIntegerLiteralContext ctx) { - - if (ctx.parameter() != null) { - return visit(ctx.parameter()); - } else if (ctx.INTEGER_LITERAL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INTEGER_LITERAL())); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitParameterOrNumberLiteral(HqlParser.ParameterOrNumberLiteralContext ctx) { - - if (ctx.parameter() != null) { - return visit(ctx.parameter()); - } else if (ctx.numericLiteral() != null) { - return visit(ctx.numericLiteral()); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitVariable(HqlParser.VariableContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.identifier() != null) { - - builder.append(QueryTokens.expression(ctx.AS())); - builder.append(visit(ctx.identifier())); - } else if (ctx.nakedIdentifier() != null) { - builder.append(visit(ctx.nakedIdentifier())); - } - - return builder; - } - @Override public QueryTokenStream visitParameter(HqlParser.ParameterContext ctx) { @@ -3949,7 +2091,7 @@ public QueryTokenStream visitParameter(HqlParser.ParameterContext ctx) { builder.append(TOKEN_QUESTION_MARK); if (ctx.INTEGER_LITERAL() != null) { - builder.append(QueryTokens.expression(ctx.INTEGER_LITERAL())); + builder.append(QueryTokens.token(ctx.INTEGER_LITERAL())); } } @@ -3962,35 +2104,19 @@ public QueryTokenStream visitEntityName(HqlParser.EntityNameContext ctx) { } @Override - public QueryTokenStream visitIdentifier(HqlParser.IdentifierContext ctx) { - - if (ctx.nakedIdentifier() != null) { - return visit(ctx.nakedIdentifier()); - } else if (ctx.FULL() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.FULL())); - } else if (ctx.LEFT() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.LEFT())); - } else if (ctx.INNER() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.INNER())); - } else if (ctx.OUTER() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.OUTER())); - } else if (ctx.RIGHT() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.RIGHT())); - } + public QueryTokenStream visitChildren(RuleNode node) { - return QueryTokenStream.empty(); - } + int childCount = node.getChildCount(); - @Override - public QueryTokenStream visitNakedIdentifier(HqlParser.NakedIdentifierContext ctx) { + if (childCount == 1 && node.getChild(0) instanceof RuleContext t) { + return visit(t); + } - if (ctx.IDENTIFIER() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.IDENTIFIER())); - } else if (ctx.QUOTED_IDENTIFIER() != null) { - return QueryRendererBuilder.from(QueryTokens.token(ctx.QUOTED_IDENTIFIER())); - } else { - return QueryRendererBuilder.from(QueryTokens.token(ctx.f)); + if (childCount == 1 && node.getChild(0) instanceof TerminalNode t) { + return QueryTokens.token(t); } + + return QueryTokenStream.concatExpressions(node, this::visit); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java index eadee9496d..46a22d1ecb 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/HqlSortedQueryTransformer.java @@ -19,10 +19,11 @@ 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.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; @@ -31,6 +32,7 @@ * * @author Greg Turnquist * @author Christoph Strobl + * @author Oscar Fanchin * @since 3.1 */ @SuppressWarnings("ConstantValue") @@ -91,20 +93,32 @@ public QueryTokenStream visitSelectionList(HqlParser.SelectionListContext ctx) { QueryTokenStream tokenStream = super.visitSelectionList(ctx); - if (dtoDelegate != null && !isSubquery(ctx)) { - return dtoDelegate.transformSelectionList(tokenStream); + 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.getLast()); + transformerSupport.registerAlias(tokens.getRequiredLast()); } return tokens; @@ -116,7 +130,19 @@ public QueryTokenStream visitJoinSubquery(HqlParser.JoinSubqueryContext ctx) { QueryTokenStream tokens = super.visitJoinSubquery(ctx); if (ctx.variable() != null && !tokens.isEmpty() && !isSubquery(ctx)) { - transformerSupport.registerAlias(tokens.getLast()); + transformerSupport.registerAlias(tokens.getRequiredLast()); + } + + return tokens; + } + + @Override + public QueryTokenStream visitJoinFunctionCall(HqlParser.JoinFunctionCallContext ctx) { + + QueryTokenStream tokens = super.visitJoinFunctionCall(ctx); + + if (ctx.variable() != null && !tokens.isEmpty()) { + transformerSupport.registerAlias(tokens.getRequiredLast()); } return tokens; @@ -128,7 +154,7 @@ public QueryTokenStream visitVariable(HqlParser.VariableContext ctx) { QueryTokenStream tokens = super.visitVariable(ctx); if (ctx.identifier() != null && !tokens.isEmpty() && !isSubquery(ctx)) { - transformerSupport.registerAlias(tokens.getLast()); + transformerSupport.registerAlias(tokens.getRequiredLast()); } return tokens; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancer.java index 57f547a06a..bf3f70df59 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 @@ -51,11 +51,12 @@ import java.util.function.Predicate; import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Sort; import org.springframework.data.util.Predicates; -import org.springframework.lang.Nullable; 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; @@ -72,37 +73,37 @@ */ 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[] serialized; + private final byte @Nullable [] serialized; /** * @param query the query we want to enhance. Must not be {@literal null}. */ - public JSqlParserQueryEnhancer(DeclaredQuery query) { + public JSqlParserQueryEnhancer(QueryProvider query) { this.query = query; - this.statement = parseStatement(query.getQueryString(), Statement.class); + Statement statement = parseStatement(query.getQueryString(), Statement.class); this.parsedType = detectParsedType(statement); this.hasConstructorExpression = QueryUtils.hasConstructorExpression(query.getQueryString()); - this.primaryAlias = detectAlias(this.parsedType, this.statement); - this.projection = detectProjection(this.statement); - this.selectAliases = Collections.unmodifiableSet(getSelectionAliases(this.statement)); - this.joinAliases = Collections.unmodifiableSet(getJoinAliases(this.statement)); - this.serialized = SerializationUtils.serialize(this.statement); + 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); } /** * Parses a query string with JSqlParser. * - * @param query the query to parse + * @param sql the query to parse + * @param classOfT the query to parse * @return the parsed query */ static T parseStatement(String sql, Class classOfT) { @@ -134,8 +135,7 @@ static T parseStatement(String sql, Class classOfT) { * * @return Might return {@literal null}. */ - @Nullable - private static String detectAlias(ParsedType parsedType, Statement statement) { + private static @Nullable String detectAlias(ParsedType parsedType, Statement statement) { if (ParsedType.MERGE.equals(parsedType)) { @@ -215,8 +215,8 @@ private static Set getJoinAliases(Statement statement) { * @param statement * @param mapper * @param fallback - * @return * @param + * @return */ private static T doWithPlainSelect(Statement statement, java.util.function.Function mapper, Supplier fallback) { @@ -236,8 +236,8 @@ private static T doWithPlainSelect(Statement statement, java.util.function.F * @param skipIf * @param mapper * @param fallback - * @return * @param + * @return */ private static T doWithPlainSelect(Statement statement, Predicate skipIf, java.util.function.Function mapper, Supplier fallback) { @@ -316,7 +316,7 @@ public boolean hasConstructorExpression() { } @Override - public String detectAlias() { + public @Nullable String detectAlias() { return this.primaryAlias; } @@ -325,35 +325,20 @@ public String getProjection() { return this.projection; } - @Override - public Set getJoinAliases() { - return joinAliases; - } - public Set getSelectionAliases() { return selectAliases; } @Override - public DeclaredQuery getQuery() { + public QueryProvider getQuery() { return this.query; } - @Override - public String applySorting(Sort sort) { - return doApplySorting(sort, detectAlias()); - } - @Override public String rewrite(QueryRewriteInformation rewriteInformation) { return doApplySorting(rewriteInformation.getSort(), primaryAlias); } - @Override - public String applySorting(Sort sort, @Nullable String alias) { - return doApplySorting(sort, alias); - } - private String doApplySorting(Sort sort, @Nullable String alias) { String queryString = query.getQueryString(); Assert.hasText(queryString, "Query must not be null or empty"); @@ -362,10 +347,12 @@ private String doApplySorting(Sort sort, @Nullable String alias) { return queryString; } - return applySorting((Select) deserialize(this.serialized), sort, alias); + return applySorting(deserializeRequired(this.serialized, Select.class), sort, alias); } - private String applySorting(Select selectStatement, Sort sort, @Nullable String alias) { + private String applySorting(@Nullable Select selectStatement, Sort sort, @Nullable String alias) { + + Assert.notNull(selectStatement, "SelectStatement must not be null"); if (selectStatement instanceof SetOperationList setOperationList) { return applySortingToSetOperationList(setOperationList, sort); @@ -392,6 +379,7 @@ private String applySorting(Select selectStatement, Sort sort, @Nullable String } @Override + @SuppressWarnings("NullAway") public String createCountQueryFor(@Nullable String countProjection) { if (this.parsedType != ParsedType.SELECT) { @@ -560,7 +548,7 @@ private static boolean onlyASingleColumnProjection(List> projectio * */ enum ParsedType { - DELETE, UPDATE, SELECT, INSERT, MERGE, OTHER; + DELETE, UPDATE, SELECT, INSERT, MERGE, OTHER } /** @@ -569,7 +557,10 @@ enum ParsedType { * @param bytes a serialized object * @return the result of deserializing the bytes */ - private static Object deserialize(byte[] bytes) { + private static @Nullable Object deserialize(byte @Nullable [] bytes) { + if (ObjectUtils.isEmpty(bytes)) { + return null; + } try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) { return ois.readObject(); } catch (IOException ex) { @@ -579,4 +570,12 @@ private static Object deserialize(byte[] bytes) { } } + 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"); + } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Jpa21Utils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Jpa21Utils.java index 4530aac26b..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 @@ -26,8 +26,9 @@ import java.util.List; import org.springframework.data.jpa.repository.support.MutableQueryHints; + +import org.jspecify.annotations.Nullable; import org.springframework.data.jpa.repository.support.QueryHints; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -63,7 +64,7 @@ public static QueryHints getFetchGraphHint(EntityManager em, JpaEntityGraph enti * 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}. @@ -188,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()); @@ -203,8 +203,7 @@ private static AttributeNode findAttributeNode(String attributeNodeName, Enti * @param nodes * @return {@literal null} if not found. */ - @Nullable - private static AttributeNode findAttributeNode(String attributeNodeName, List> nodes) { + private static @Nullable AttributeNode findAttributeNode(String attributeNodeName, List> nodes) { for (AttributeNode node : nodes) { if (ObjectUtils.nullSafeEquals(node.getAttributeName(), attributeNodeName)) { @@ -223,8 +222,7 @@ private static AttributeNode findAttributeNode(String attributeNodeName, List * @param node * @return */ - @Nullable - private static Subgraph getSubgraph(AttributeNode node) { + private static @Nullable Subgraph getSubgraph(AttributeNode node) { return node.getSubgraphs().isEmpty() ? null : node.getSubgraphs().values().iterator().next(); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreator.java index d9b69f362f..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 @@ -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. @@ -39,37 +36,56 @@ public class JpaCountQueryCreator extends JpaQueryCreator { 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) { - return builder.createQuery(Long.class); + /** + * Creates a new {@link JpaCountQueryCreator} + * + * @param tree + * @param returnedType + * @param provider + * @param templates + * @param metamodel + */ + public JpaCountQueryCreator(PartTree tree, ReturnedType returnedType, ParameterMetadataProvider provider, + JpqlQueryTemplates templates, Metamodel metamodel) { + + 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) { - CriteriaQuery select = query.select(getCountQuery(builder, root)); - return predicate == null ? select : select.where(predicate); + super(tree, false, returnedType, provider, templates, entityMetadata, metamodel); + + this.distinct = tree.isDistinct(); } - @SuppressWarnings("rawtypes") - private Expression getCountQuery(CriteriaBuilder builder, Root root) { - return distinct ? builder.countDistinct(root) : builder.count(root); + @Override + protected JpqlQueryBuilder.Select buildQuery(Sort sort) { + + JpqlQueryBuilder.SelectStep selectStep = JpqlQueryBuilder.selectFrom(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 3a7d9421b8..cf85c65d47 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityGraph.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaEntityGraph.java @@ -18,8 +18,9 @@ import java.util.List; import org.springframework.data.jpa.repository.EntityGraph; + +import org.jspecify.annotations.Nullable; import org.springframework.data.jpa.repository.EntityGraph.EntityGraphType; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -56,7 +57,7 @@ public JpaEntityGraph(EntityGraph entityGraph, String nameFallback) { * @param attributePaths may be {@literal null}. * @since 1.9 */ - public JpaEntityGraph(String name, EntityGraphType type, @Nullable String[] attributePaths) { + public JpaEntityGraph(String name, EntityGraphType type, String @Nullable[] attributePaths) { Assert.hasText(name, "The name of an EntityGraph must not be null or empty"); Assert.notNull(type, "FetchGraphType must not be null"); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaKeysetScrollQueryCreator.java index c6f3d9b4e6..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 @@ -15,19 +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.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}. @@ -37,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 - protected CriteriaQuery complete(@Nullable Predicate predicate, Sort sort, CriteriaQuery query, - CriteriaBuilder builder, Root root) { + 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 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 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 b3fc5526f5..f94f4ba8c6 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaParameters.java @@ -23,13 +23,14 @@ import java.util.function.Function; import org.springframework.core.MethodParameter; + +import org.jspecify.annotations.Nullable; import org.springframework.data.jpa.repository.Temporal; import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; import org.springframework.data.repository.query.Parameter; import org.springframework.data.repository.query.Parameters; import org.springframework.data.repository.query.ParametersSource; import org.springframework.data.util.TypeInformation; -import org.springframework.lang.Nullable; /** * Custom extension of {@link Parameters} discovering additional query parameter annotations. @@ -63,7 +64,7 @@ protected JpaParameters(ParametersSource parametersSource, super(parametersSource, parameterFactory); } - private JpaParameters(List 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 e222439a22..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 @@ -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 73bafaf249..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 @@ -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,56 +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(); } /** @@ -113,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); + } + + List paths = new ArrayList<>(requiredSelection.size()); + for (String selection : requiredSelection) { + paths.add(JpqlUtils.toExpressionRecursively(metamodel, entity, entityType, + PropertyPath.from(selection, returnedType.getDomainType()), true)); + } + + JpqlQueryBuilder.Expression distance = null; + if (searchQuery) { + distance = getDistanceExpression(); + } + + if (useTupleQuery()) { + + if (searchQuery) { + paths.add((distance != null ? distance : JpqlQueryBuilder.literal(0)).as("distance")); + } + return selectStep.select(paths); + } else { + + JpqlQueryBuilder.ConstructorExpression expression = new JpqlQueryBuilder.ConstructorExpression( + returnedType.getReturnedType().getName(), new JpqlQueryBuilder.Multiselect(entity, paths)); + + List selection = new ArrayList<>(2); + selection.add(expression); - for (String property : requiredSelection) { + if (searchQuery) { + selection.add((distance != null ? distance : JpqlQueryBuilder.literal(0)).as("distance")); + } - PropertyPath path = PropertyPath.from(property, returnedType.getDomainType()); - selections.add(toExpressionRecursively(root, path, true).alias(property)); + return selectStep.select(selection); } + } + + if (searchQuery) { - Class typeToRead = returnedType.getReturnedType(); + JpqlQueryBuilder.Expression distance = getDistanceExpression(); - query = typeToRead.isInterface() // - ? query.multiselect(selections) // - : query.select((Selection) builder.construct(typeToRead, // - selections.toArray(new Selection[0]))); + if (distance != null) { + return selectStep.select(new JpqlQueryBuilder.Multiselect(entity, + Arrays.asList(new JpqlQueryBuilder.EntitySelection(entity), distance.as("distance")))); + } + } - } else if (tree.isExistsProjection()) { + if (tree.isExistsProjection()) { - if (root.getModel().hasSingleIdAttribute()) { + if (entityType.hasSingleIdAttribute()) { - SingularAttribute id = root.getModel().getId(root.getModel().getIdType().getJavaType()); - query = query.multiselect(root.get((SingularAttribute) id).alias(id.getName())); + 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() { + + 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(); + } + } } - CriteriaQuery select = query.orderBy(QueryUtils.toOrders(sort, root, builder)); - return predicate == null ? select : select.where(predicate); + 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(); } /** @@ -216,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; } /** @@ -242,83 +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: case NEGATING_SIMPLE_PROPERTY: - ParameterMetadata expression = provider.next(part); - Expression path = getTypedPath(root, part); + PartTreeParameterBinding simple = provider.next(part); - if (expression.isIsNullParameter()) { - return type.equals(SIMPLE_PROPERTY) ? path.isNull() : path.isNotNull(); - } else { - return type.equals(SIMPLE_PROPERTY) - ? builder.equal(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression())) - : builder.notEqual(upperIfIgnoreCase(path), upperIfIgnoreCase(expression.getExpression())); + if (simple.isIsNullParameter()) { + return type.equals(SIMPLE_PROPERTY) ? where.isNull() : where.isNotNull(); } + + JpqlQueryBuilder.Expression expression = potentiallyIgnoreCase(property, placeholder(simple)); + return type.equals(SIMPLE_PROPERTY) ? whereIgnoreCase.eq(expression) : whereIgnoreCase.neq(expression); case IS_EMPTY: case IS_NOT_EMPTY: @@ -326,77 +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 d6d4cbeda1..9c6e3e30bb 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancer.java @@ -16,7 +16,6 @@ package org.springframework.data.jpa.repository.query; import java.util.List; -import java.util.Set; import java.util.function.BiFunction; import java.util.function.Function; @@ -32,11 +31,10 @@ import org.antlr.v4.runtime.atn.PredictionMode; import org.antlr.v4.runtime.misc.ParseCancellationException; import org.antlr.v4.runtime.tree.ParseTreeVisitor; +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Sort; import org.springframework.data.repository.query.ReturnedType; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; /** * Implementation of {@link QueryEnhancer} to enhance JPA queries using ANTLR parsers. @@ -48,18 +46,17 @@ * @see HqlQueryParser * @see EqlQueryParser */ -@SuppressWarnings("removal") class JpaQueryEnhancer implements QueryEnhancer { private final ParserRuleContext context; private final Q queryInformation; private final String projection; private final SortedQueryRewriteFunction sortFunction; - private final BiFunction> countQueryFunction; + private final BiFunction<@Nullable String, Q, ParseTreeVisitor> countQueryFunction; JpaQueryEnhancer(ParserRuleContext context, ParsedQueryIntrospector introspector, SortedQueryRewriteFunction sortFunction, - BiFunction> countQueryFunction) { + BiFunction<@Nullable String, Q, ParseTreeVisitor> countQueryFunction) { this.context = context; this.sortFunction = sortFunction; @@ -142,43 +139,34 @@ static void configureParser(String query, String grammar, Lexer lexer, Parser pa } /** - * Factory method to create a {@link JpaQueryEnhancer} for {@link DeclaredQuery} using JPQL grammar. + * Factory method to create a {@link JpaQueryEnhancer} for {@link ParametrizedQuery} using JPQL grammar. * * @param query must not be {@literal null}. * @return a new {@link JpaQueryEnhancer} using JPQL. */ - public static JpaQueryEnhancer forJpql(DeclaredQuery query) { - - Assert.notNull(query, "DeclaredQuery must not be null!"); - - return JpqlQueryParser.parseQuery(query.getQueryString()); + public static JpaQueryEnhancer forJpql(String query) { + return JpqlQueryParser.parseQuery(query); } /** - * Factory method to create a {@link JpaQueryEnhancer} for {@link DeclaredQuery} using HQL grammar. + * Factory method to create a {@link JpaQueryEnhancer} for {@link ParametrizedQuery} using HQL grammar. * * @param query must not be {@literal null}. * @return a new {@link JpaQueryEnhancer} using HQL. */ - public static JpaQueryEnhancer forHql(DeclaredQuery query) { - - Assert.notNull(query, "DeclaredQuery must not be null!"); - - return HqlQueryParser.parseQuery(query.getQueryString()); + public static JpaQueryEnhancer forHql(String query) { + return HqlQueryParser.parseQuery(query); } /** - * Factory method to create a {@link JpaQueryEnhancer} for {@link DeclaredQuery} using EQL grammar. + * Factory method to create a {@link JpaQueryEnhancer} for {@link ParametrizedQuery} using EQL grammar. * * @param query must not be {@literal null}. * @return a new {@link JpaQueryEnhancer} using EQL. * @since 3.2 */ - public static JpaQueryEnhancer forEql(DeclaredQuery query) { - - Assert.notNull(query, "DeclaredQuery must not be null!"); - - return EqlQueryParser.parseQuery(query.getQueryString()); + public static JpaQueryEnhancer forEql(String query) { + return EqlQueryParser.parseQuery(query); } /** @@ -206,50 +194,28 @@ public boolean hasConstructorExpression() { } /** - * Resolves the alias for the entity in the FROM clause from the JPA query. Since the {@link JpaQueryParser} can - * already find the alias when generating sorted and count queries, this is mainly to serve test cases. + * Resolves the alias for the entity in the FROM clause from the JPA query. */ @Override - public String detectAlias() { + public @Nullable String detectAlias() { return this.queryInformation.getAlias(); } /** - * Looks up the projection of the JPA query. Since the {@link JpaQueryParser} can already find the projection when - * generating sorted and count queries, this is mainly to serve test cases. + * Looks up the projection of the JPA query. */ @Override public String getProjection() { return this.projection; } - /** - * Since the parser can already fully transform sorted and count queries by itself, this is a placeholder method. - * - * @return empty set - */ - @Override - public Set getJoinAliases() { - return Set.of(); - } - /** * Look up the {@link DeclaredQuery} from the query parser. */ @Override public DeclaredQuery getQuery() { - throw new UnsupportedOperationException(); - } - - /** - * Adds an {@literal order by} clause to the JPA query. - * - * @param sort the sort specification to apply. - * @return - */ - @Override - public String applySorting(Sort sort) { - return QueryRenderer.TokenRenderer.render(sortFunction.apply(sort, this.queryInformation, null).visit(context)); + QueryTokenStream tokens = sortFunction.apply(Sort.unsorted(), this.queryInformation, null).visit(context); + return DeclaredQuery.jpqlQuery(QueryRenderer.TokenRenderer.render(tokens)); } @Override @@ -259,28 +225,6 @@ public String rewrite(QueryRewriteInformation rewriteInformation) { .visit(context)); } - /** - * Because the parser can find the alias of the FROM clause, there is no need to "find it" in advance. - * - * @param sort the sort specification to apply. - * @param alias IGNORED - * @return - */ - @Override - public String applySorting(Sort sort, String alias) { - return applySorting(sort); - } - - /** - * Creates a count query from the original query, with no count projection. - * - * @return Guaranteed to be not {@literal null}; - */ - @Override - public String createCountQueryFor() { - return createCountQueryFor(null); - } - /** * Create a count query from the original query, with potential custom projection. * diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryExecution.java index 35a680c8fe..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 @@ -16,33 +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; /** @@ -81,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; @@ -117,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. @@ -131,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. * @@ -149,7 +225,7 @@ static class ScrollExecution extends JpaQueryExecution { } @Override - @SuppressWarnings("unchecked") + @SuppressWarnings("NullAway") protected Object doExecute(AbstractJpaQuery query, JpaParametersParameterAccessor accessor) { ScrollPosition scrollPosition = accessor.getScrollPosition(); @@ -196,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) { @@ -203,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(); } } @@ -219,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(); } } @@ -233,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 @@ -241,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(); @@ -290,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); } } @@ -334,7 +458,7 @@ static class ProcedureExecution extends JpaQueryExecution { } @Override - protected Object doExecute(AbstractJpaQuery jpaQuery, JpaParametersParameterAccessor accessor) { + protected @Nullable Object doExecute(AbstractJpaQuery jpaQuery, JpaParametersParameterAccessor accessor) { Assert.isInstanceOf(StoredProcedureJpaQuery.class, jpaQuery); @@ -379,10 +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 82babfb9e4..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryFactory.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2013-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.query; - -import jakarta.persistence.EntityManager; - -import org.springframework.data.jpa.repository.QueryRewriter; -import org.springframework.data.repository.query.QueryCreationException; -import org.springframework.data.repository.query.RepositoryQuery; -import org.springframework.data.repository.query.ValueExpressionDelegate; -import org.springframework.lang.Nullable; - -/** - * Factory to create the appropriate {@link RepositoryQuery} for a {@link JpaQueryMethod}. - * - * @author Thomas Darimont - * @author Mark Paluch - */ -enum JpaQueryFactory { - - INSTANCE; - - /** - * Creates a {@link RepositoryQuery} from the given {@link String} query. - */ - AbstractJpaQuery fromMethodWithQueryString(JpaQueryMethod method, EntityManager em, String queryString, - @Nullable String countQueryString, QueryRewriter queryRewriter, - ValueExpressionDelegate valueExpressionDelegate) { - - if (method.isScrollQuery()) { - throw QueryCreationException.create(method, "Scroll queries are not supported using String-based queries"); - } - - return method.isNativeQuery() - ? new NativeJpaQuery(method, em, queryString, countQueryString, queryRewriter, valueExpressionDelegate) - : new SimpleJpaQuery(method, em, queryString, countQueryString, queryRewriter, valueExpressionDelegate); - } - - /** - * Creates a {@link StoredProcedureJpaQuery} from the given {@link JpaQueryMethod} query. - * - * @param method must not be {@literal null}. - * @param em must not be {@literal null}. - * @return - */ - public StoredProcedureJpaQuery fromProcedureAnnotation(JpaQueryMethod method, EntityManager em) { - - if (method.isScrollQuery()) { - throw QueryCreationException.create(method, "Scroll queries are not supported using stored procedures"); - } - - return new StoredProcedureJpaQuery(method, em); - } -} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategy.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategy.java index 6d25af839a..37f2e27d2a 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategy.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryLookupStrategy.java @@ -21,21 +21,17 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; -import org.springframework.core.env.StandardEnvironment; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.core.NamedQueries; import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.QueryCreationException; import org.springframework.data.repository.query.QueryLookupStrategy; import org.springframework.data.repository.query.QueryLookupStrategy.Key; import org.springframework.data.repository.query.QueryMethod; -import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; -import org.springframework.data.repository.query.QueryMethodValueEvaluationContextAccessor; import org.springframework.data.repository.query.RepositoryQuery; -import org.springframework.data.repository.query.ValueExpressionDelegate; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -73,33 +69,31 @@ private abstract static class AbstractQueryLookupStrategy implements QueryLookup private final EntityManager em; private final JpaQueryMethodFactory queryMethodFactory; - private final QueryRewriterProvider queryRewriterProvider; + private final JpaQueryConfiguration configuration; /** * Creates a new {@link AbstractQueryLookupStrategy}. * * @param em must not be {@literal null}. * @param queryMethodFactory must not be {@literal null}. + * @param configuration must not be {@literal null}. */ public AbstractQueryLookupStrategy(EntityManager em, JpaQueryMethodFactory queryMethodFactory, - QueryRewriterProvider queryRewriterProvider) { - - Assert.notNull(em, "EntityManager must not be null"); - Assert.notNull(queryMethodFactory, "JpaQueryMethodFactory must not be null"); + JpaQueryConfiguration configuration) { this.em = em; this.queryMethodFactory = queryMethodFactory; - this.queryRewriterProvider = queryRewriterProvider; + this.configuration = configuration; } @Override public final RepositoryQuery resolveQuery(Method method, RepositoryMetadata metadata, ProjectionFactory factory, NamedQueries namedQueries) { JpaQueryMethod queryMethod = queryMethodFactory.build(method, metadata, factory); - return resolveQuery(queryMethod, queryRewriterProvider.getQueryRewriter(queryMethod), em, namedQueries); + return resolveQuery(queryMethod, configuration, em, namedQueries); } - protected abstract RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter queryRewriter, + protected abstract RepositoryQuery resolveQuery(JpaQueryMethod method, JpaQueryConfiguration configuration, EntityManager em, NamedQueries namedQueries); } @@ -112,20 +106,16 @@ protected abstract RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewr */ private static class CreateQueryLookupStrategy extends AbstractQueryLookupStrategy { - private final EscapeCharacter escape; - public CreateQueryLookupStrategy(EntityManager em, JpaQueryMethodFactory queryMethodFactory, - QueryRewriterProvider queryRewriterProvider, EscapeCharacter escape) { - - super(em, queryMethodFactory, queryRewriterProvider); + JpaQueryConfiguration configuration) { - this.escape = escape; + super(em, queryMethodFactory, configuration); } @Override - protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter queryRewriter, EntityManager em, + protected RepositoryQuery resolveQuery(JpaQueryMethod method, JpaQueryConfiguration configuration, EntityManager em, NamedQueries namedQueries) { - return new PartTreeJpaQuery(method, em, escape); + return new PartTreeJpaQuery(method, em, configuration.getEscapeCharacter()); } } @@ -137,57 +127,62 @@ protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter quer * @author Thomas Darimont * @author Jens Schauder */ - private static class DeclaredQueryLookupStrategy extends AbstractQueryLookupStrategy { - - private final ValueExpressionDelegate valueExpressionDelegate; + static class DeclaredQueryLookupStrategy extends AbstractQueryLookupStrategy { /** * Creates a new {@link DeclaredQueryLookupStrategy}. * * @param em must not be {@literal null}. * @param queryMethodFactory must not be {@literal null}. - * @param evaluationContextProvider must not be {@literal null}. + * @param configuration must not be {@literal null}. */ public DeclaredQueryLookupStrategy(EntityManager em, JpaQueryMethodFactory queryMethodFactory, - ValueExpressionDelegate delegate, QueryRewriterProvider queryRewriterProvider) { - - super(em, queryMethodFactory, queryRewriterProvider); + JpaQueryConfiguration configuration) { - this.valueExpressionDelegate = delegate; + super(em, queryMethodFactory, configuration); } @Override - protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter queryRewriter, EntityManager em, + protected RepositoryQuery resolveQuery(JpaQueryMethod method, JpaQueryConfiguration configuration, EntityManager em, NamedQueries namedQueries) { if (method.isProcedureQuery()) { - return JpaQueryFactory.INSTANCE.fromProcedureAnnotation(method, em); + return createProcedureQuery(method, em); } - if (StringUtils.hasText(method.getAnnotatedQuery())) { + if (method.hasAnnotatedQuery()) { if (method.hasAnnotatedQueryName()) { LOG.warn(String.format( "Query method %s is annotated with both, a query and a query name; Using the declared query", method)); } - return JpaQueryFactory.INSTANCE.fromMethodWithQueryString(method, em, method.getRequiredAnnotatedQuery(), - getCountQuery(method, namedQueries, em), queryRewriter, valueExpressionDelegate); + return createStringQuery(method, em, method.getRequiredDeclaredQuery(), + getCountQuery(method, namedQueries, em), configuration); } String name = method.getNamedQueryName(); + if (namedQueries.hasQuery(name)) { - return JpaQueryFactory.INSTANCE.fromMethodWithQueryString(method, em, namedQueries.getQuery(name), - getCountQuery(method, namedQueries, em), queryRewriter, valueExpressionDelegate); + return createStringQuery(method, em, method.getDeclaredQuery(namedQueries.getQuery(name)), + getCountQuery(method, namedQueries, em), + configuration); } - RepositoryQuery query = NamedQuery.lookupFrom(method, em, queryRewriter); + RepositoryQuery query = NamedQuery.lookupFrom(method, em, configuration); return query != null ? query : NO_QUERY; } - @Nullable - private String getCountQuery(JpaQueryMethod method, NamedQueries namedQueries, EntityManager em) { + private @Nullable DeclaredQuery getCountQuery(JpaQueryMethod method, NamedQueries namedQueries, EntityManager em) { + + String query = doGetCountQuery(method, namedQueries, em); + + return StringUtils.hasText(query) ? method.getDeclaredQuery(query) : null; + } + + private static @Nullable String doGetCountQuery(JpaQueryMethod method, NamedQueries namedQueries, + EntityManager em) { if (StringUtils.hasText(method.getCountQuery())) { return method.getCountQuery(); @@ -211,6 +206,44 @@ private String getCountQuery(JpaQueryMethod method, NamedQueries namedQueries, E return null; } + + /** + * Creates a {@link RepositoryQuery} from the given {@link String} query. + * + * @param method must not be {@literal null}. + * @param em must not be {@literal null}. + * @param query must not be {@literal null}. + * @param countQuery can be {@literal null} if not defined. + * @param configuration must not be {@literal null}. + * @return + */ + static AbstractJpaQuery createStringQuery(JpaQueryMethod method, EntityManager em, DeclaredQuery query, + @Nullable DeclaredQuery countQuery, JpaQueryConfiguration configuration) { + + if (method.isScrollQuery()) { + throw QueryCreationException.create(method, "Scroll queries are not supported using String-based queries"); + } + + return method.isNativeQuery() ? new NativeJpaQuery(method, em, query, countQuery, configuration) + : new SimpleJpaQuery(method, em, query, countQuery, configuration); + } + + /** + * Creates a {@link StoredProcedureJpaQuery} from the given {@link JpaQueryMethod} query. + * + * @param method must not be {@literal null}. + * @param em must not be {@literal null}. + * @return + */ + static StoredProcedureJpaQuery createProcedureQuery(JpaQueryMethod method, EntityManager em) { + + if (method.isScrollQuery()) { + throw QueryCreationException.create(method, "Scroll queries are not supported using stored procedures"); + } + + return new StoredProcedureJpaQuery(method, em); + } + } /** @@ -233,31 +266,29 @@ private static class CreateIfNotFoundQueryLookupStrategy extends AbstractQueryLo * @param queryMethodFactory must not be {@literal null}. * @param createStrategy must not be {@literal null}. * @param lookupStrategy must not be {@literal null}. + * @param configuration must not be {@literal null}. */ public CreateIfNotFoundQueryLookupStrategy(EntityManager em, JpaQueryMethodFactory queryMethodFactory, CreateQueryLookupStrategy createStrategy, DeclaredQueryLookupStrategy lookupStrategy, - QueryRewriterProvider queryRewriterProvider) { - - super(em, queryMethodFactory, queryRewriterProvider); + JpaQueryConfiguration configuration) { - Assert.notNull(createStrategy, "CreateQueryLookupStrategy must not be null"); - Assert.notNull(lookupStrategy, "DeclaredQueryLookupStrategy must not be null"); + super(em, queryMethodFactory, configuration); this.createStrategy = createStrategy; this.lookupStrategy = lookupStrategy; } @Override - protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter queryRewriter, EntityManager em, + protected RepositoryQuery resolveQuery(JpaQueryMethod method, JpaQueryConfiguration configuration, EntityManager em, NamedQueries namedQueries) { - RepositoryQuery lookupQuery = lookupStrategy.resolveQuery(method, queryRewriter, em, namedQueries); + RepositoryQuery lookupQuery = lookupStrategy.resolveQuery(method, configuration, em, namedQueries); if (lookupQuery != NO_QUERY) { return lookupQuery; } - return createStrategy.resolveQuery(method, queryRewriter, em, namedQueries); + return createStrategy.resolveQuery(method, configuration, em, namedQueries); } } @@ -267,47 +298,20 @@ protected RepositoryQuery resolveQuery(JpaQueryMethod method, QueryRewriter quer * @param em must not be {@literal null}. * @param queryMethodFactory must not be {@literal null}. * @param key may be {@literal null}. - * @param evaluationContextProvider must not be {@literal null}. - * @param escape must not be {@literal null}. - * @deprecated since 3.4, use - * {@link #create(EntityManager, JpaQueryMethodFactory, Key, ValueExpressionDelegate, QueryRewriterProvider, EscapeCharacter)} - * instead. - */ - @Deprecated(since = "3.4") - public static QueryLookupStrategy create(EntityManager em, JpaQueryMethodFactory queryMethodFactory, - @Nullable Key key, QueryMethodEvaluationContextProvider evaluationContextProvider, - QueryRewriterProvider queryRewriterProvider, EscapeCharacter escape) { - return create(em, queryMethodFactory, key, - new ValueExpressionDelegate(new QueryMethodValueEvaluationContextAccessor(new StandardEnvironment(), - evaluationContextProvider.getEvaluationContextProvider()), ValueExpressionDelegate.create()), - queryRewriterProvider, escape); - } - - /** - * Creates a {@link QueryLookupStrategy} for the given {@link EntityManager} and {@link Key}. - * - * @param em must not be {@literal null}. - * @param queryMethodFactory must not be {@literal null}. - * @param key may be {@literal null}. - * @param delegate must not be {@literal null}. - * @param queryRewriterProvider must not be {@literal null}. - * @param escape must not be {@literal null}. + * @param configuration must not be {@literal null}. */ public static QueryLookupStrategy create(EntityManager em, JpaQueryMethodFactory queryMethodFactory, - @Nullable Key key, ValueExpressionDelegate delegate, QueryRewriterProvider queryRewriterProvider, - EscapeCharacter escape) { + @Nullable Key key, JpaQueryConfiguration configuration) { Assert.notNull(em, "EntityManager must not be null"); - Assert.notNull(delegate, "ValueExpressionDelegate must not be null"); + Assert.notNull(configuration, "JpaQueryConfiguration must not be null"); return switch (key != null ? key : Key.CREATE_IF_NOT_FOUND) { - case CREATE -> new CreateQueryLookupStrategy(em, queryMethodFactory, queryRewriterProvider, escape); - case USE_DECLARED_QUERY -> - new DeclaredQueryLookupStrategy(em, queryMethodFactory, delegate, queryRewriterProvider); + case CREATE -> new CreateQueryLookupStrategy(em, queryMethodFactory, configuration); + case USE_DECLARED_QUERY -> new DeclaredQueryLookupStrategy(em, queryMethodFactory, configuration); case CREATE_IF_NOT_FOUND -> new CreateIfNotFoundQueryLookupStrategy(em, queryMethodFactory, - new CreateQueryLookupStrategy(em, queryMethodFactory, queryRewriterProvider, escape), - new DeclaredQueryLookupStrategy(em, queryMethodFactory, delegate, queryRewriterProvider), - queryRewriterProvider); + new CreateQueryLookupStrategy(em, queryMethodFactory, configuration), + new DeclaredQueryLookupStrategy(em, queryMethodFactory, configuration), configuration); default -> throw new IllegalArgumentException(String.format("Unsupported query lookup strategy %s", key)); }; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryMethod.java index 39202fcb77..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 @@ -27,6 +27,8 @@ import java.util.Set; import java.util.function.Function; +import org.jspecify.annotations.Nullable; + import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.core.annotation.AnnotationUtils; import org.springframework.data.jpa.provider.QueryExtractor; @@ -41,11 +43,11 @@ import org.springframework.data.repository.core.RepositoryMetadata; 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.StringUtils; @@ -147,8 +149,10 @@ public JpaQueryMethod(Method method, RepositoryMetadata metadata, ProjectionFact 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)); + 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) { @@ -164,7 +168,6 @@ private static Class potentiallyUnwrapReturnTypeFor(RepositoryMetadata metada } @Override - @SuppressWarnings({ "rawtypes", "unchecked" }) public JpaEntityMetadata getEntityInformation() { return this.entityMetadata.get(); } @@ -232,7 +235,7 @@ boolean applyHintsToCountQuery() { * * @return */ - QueryExtractor getQueryExtractor() { + public QueryExtractor getQueryExtractor() { return extractor; } @@ -295,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; @@ -334,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. @@ -370,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() { @@ -382,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"; @@ -418,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); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryTransformerSupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryTransformerSupport.java index 6cb8f11104..79a31e556f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryTransformerSupport.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaQueryTransformerSupport.java @@ -9,10 +9,11 @@ import java.util.regex.Pattern; import org.springframework.dao.InvalidDataAccessApiUsageException; + +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.NullHandling; import org.springframework.data.jpa.domain.JpaSort; -import org.springframework.lang.Nullable; import org.springframework.util.ObjectUtils; /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaResultConverters.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpaResultConverters.java index 06382e5e9b..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 @@ -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 index 89e4d54070..af7686fac2 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlCountQueryTransformer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlCountQueryTransformer.java @@ -17,9 +17,11 @@ import static org.springframework.data.jpa.repository.query.QueryTokens.*; +import org.jspecify.annotations.Nullable; + import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; import org.springframework.data.jpa.repository.query.QueryTransformers.CountSelectionTokenStream; -import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; /** * An ANTLR {@link org.antlr.v4.runtime.tree.ParseTreeVisitor} that transforms a parsed JPQL query into a @@ -42,7 +44,7 @@ class JpqlCountQueryTransformer extends JpqlQueryRenderer { } @Override - public QueryRenderer.QueryRendererBuilder visitSelect_statement(JpqlParser.Select_statementContext ctx) { + public QueryTokenStream visitSelectQuery(JpqlParser.SelectQueryContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); @@ -62,6 +64,49 @@ public QueryRenderer.QueryRendererBuilder visitSelect_statement(JpqlParser.Selec 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) { @@ -77,14 +122,21 @@ public QueryRendererBuilder visitSelect_clause(JpqlParser.Select_clauseContext c if (usesDistinct) { nested.append(QueryTokens.expression(ctx.DISTINCT())); nested.append(getDistinctCountSelection(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA))); - } else { + } else if (StringUtils.hasText(primaryFromAlias)) { nested.append(QueryTokens.token(primaryFromAlias)); + } else { + 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 { - builder.append(QueryTokens.token(countProjection)); if (usesDistinct) { nested.append(QueryTokens.expression(ctx.DISTINCT())); } + nested.append(QueryTokens.token(countProjection)); } builder.appendInline(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/OpenJpaJpa21UtilsTests.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java similarity index 67% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaJpa21UtilsTests.java rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java index 4c5cac42e1..039392d571 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaJpa21UtilsTests.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryCreator.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2025 the original author or authors. + * Copyright 2024-2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,12 +15,20 @@ */ package org.springframework.data.jpa.repository.query; -import org.springframework.test.context.ContextConfiguration; +import java.util.List; + +import org.springframework.data.domain.Sort; /** - * @author Christoph Strobl + * @author Mark Paluch */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaJpa21UtilsTests extends Jpa21UtilsTests { +interface JpqlQueryCreator { + + boolean useTupleQuery(); + + String createQuery(Sort sort); + + List getBindings(); + ParameterBinder getBinder(); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryIntrospector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryIntrospector.java index 48f6fef46b..f819778ce6 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryIntrospector.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlQueryIntrospector.java @@ -21,7 +21,7 @@ import java.util.Collections; import java.util.List; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * {@link ParsedQueryIntrospector} for JPQL queries. @@ -46,39 +46,34 @@ public QueryInformation getParsedQueryInformation() { } @Override - public Void visitRange_variable_declaration(JpqlParser.Range_variable_declarationContext ctx) { + public Void visitSelect_clause(JpqlParser.Select_clauseContext ctx) { - if (primaryFromAlias == null) { - primaryFromAlias = capturePrimaryAlias(ctx); + if (!projectionProcessed) { + projection = captureSelectItems(ctx.select_item(), renderer); + projectionProcessed = true; } - return super.visitRange_variable_declaration(ctx); + return super.visitSelect_clause(ctx); } @Override - public Void visitSelect_clause(JpqlParser.Select_clauseContext ctx) { + public Void visitRange_variable_declaration(JpqlParser.Range_variable_declarationContext ctx) { - if (!projectionProcessed) { - projection = captureSelectItems(ctx.select_item(), renderer); - projectionProcessed = true; + if (primaryFromAlias == null && ctx.identification_variable() != null && !JpqlQueryRenderer.isSubquery(ctx) + && !JpqlQueryRenderer.isSetQuery(ctx)) { + primaryFromAlias = ctx.identification_variable().getText(); } - return super.visitSelect_clause(ctx); + return super.visitRange_variable_declaration(ctx); } @Override public Void visitConstructor_expression(JpqlParser.Constructor_expressionContext ctx) { hasConstructorExpression = true; - return super.visitConstructor_expression(ctx); } - private static String capturePrimaryAlias(JpqlParser.Range_variable_declarationContext ctx) { - return ctx.identification_variable() != null ? ctx.identification_variable().getText() - : ctx.entity_name().getText(); - } - private static List captureSelectItems(List selections, JpqlQueryRenderer itemRenderer) { 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 41ca183967..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 @@ -20,10 +20,12 @@ 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.JpqlParser.NullsPrecedenceContext; -import org.springframework.data.jpa.repository.query.JpqlParser.Reserved_wordContext; import org.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; import org.springframework.util.CollectionUtils; @@ -32,83 +34,58 @@ * * @author Greg Turnquist * @author Christoph Strobl + * @author Mark Paluch + * @author TaeHyun Kang * @since 3.1 */ @SuppressWarnings({ "ConstantConditions", "DuplicatedCode" }) class JpqlQueryRenderer extends JpqlBaseVisitor { - @Override - public QueryTokenStream visitStart(JpqlParser.StartContext ctx) { - return visit(ctx.ql_statement()); - } - - @Override - public QueryTokenStream 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 QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitSelect_statement(JpqlParser.Select_statementContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.select_clause())); - builder.appendExpression(visit(ctx.from_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.where_clause() != null) { - builder.appendExpression(visit(ctx.where_clause())); - } + while (ctx != null) { - if (ctx.groupby_clause() != null) { - builder.appendExpression(visit(ctx.groupby_clause())); - } + if (ctx instanceof JpqlParser.SubqueryContext) { + return true; + } - if (ctx.having_clause() != null) { - builder.appendExpression(visit(ctx.having_clause())); - } + if (ctx instanceof JpqlParser.Update_statementContext || ctx instanceof JpqlParser.Delete_statementContext) { + return false; + } - if (ctx.orderby_clause() != null) { - builder.appendExpression(visit(ctx.orderby_clause())); + ctx = ctx.getParent(); } - return builder; + return false; } - @Override - public QueryTokenStream visitUpdate_statement(JpqlParser.Update_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) { - QueryRendererBuilder builder = QueryRenderer.builder(); + while (ctx != null) { - builder.append(visit(ctx.update_clause())); + if (ctx instanceof JpqlParser.Set_fuctionContext) { + return true; + } - if (ctx.where_clause() != null) { - builder.append(visit(ctx.where_clause())); + ctx = ctx.getParent(); } - return builder; + return false; } @Override - public QueryTokenStream visitDelete_statement(JpqlParser.Delete_statementContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.delete_clause())); - - if (ctx.where_clause() != null) { - builder.appendExpression(visit(ctx.where_clause())); - } - - return builder; + public QueryTokenStream visitStart(JpqlParser.StartContext ctx) { + return visit(ctx.ql_statement()); } @Override @@ -120,12 +97,12 @@ public QueryTokenStream visitFrom_clause(JpqlParser.From_clauseContext ctx) { builder.appendInline(visit(ctx.identification_variable_declaration())); if (!ctx.identificationVariableDeclarationOrCollectionMemberDeclaration().isEmpty()) { - builder.append(TOKEN_COMMA); - builder.appendExpression(QueryTokenStream - .concat(ctx.identificationVariableDeclarationOrCollectionMemberDeclaration(), this::visit, TOKEN_COMMA)); } + builder.appendExpression(QueryTokenStream + .concat(ctx.identificationVariableDeclarationOrCollectionMemberDeclaration(), this::visit, TOKEN_COMMA)); + return builder; } @@ -133,109 +110,28 @@ public QueryTokenStream visitFrom_clause(JpqlParser.From_clauseContext ctx) { public QueryTokenStream 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 QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitIdentification_variable_declaration( - JpqlParser.Identification_variable_declarationContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - builder.appendExpression(visit(ctx.range_variable_declaration())); - - ctx.join().forEach(joinContext -> { - builder.append(visit(joinContext)); - }); - - ctx.fetch_join().forEach(fetchJoinContext -> { - builder.append(visit(fetchJoinContext)); - }); - - return builder; - } - - @Override - public QueryTokenStream visitRange_variable_declaration(JpqlParser.Range_variable_declarationContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.entity_name())); - - if (ctx.AS() != null) { - builder.append(QueryTokens.expression(ctx.AS())); - } - - builder.appendExpression(visit(ctx.identification_variable())); - - return builder; - } - - @Override - public QueryTokenStream visitJoin(JpqlParser.JoinContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.join_spec())); - builder.append(visit(ctx.join_association_path_expression())); - if (ctx.AS() != null) { - builder.append(QueryTokens.expression(ctx.AS())); - } - builder.append(visit(ctx.identification_variable())); - if (ctx.join_condition() != null) { - builder.append(visit(ctx.join_condition())); - } - - return builder; - } - - @Override - public QueryTokenStream visitFetch_join(JpqlParser.Fetch_joinContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.subquery() != null) { - builder.append(visit(ctx.join_spec())); - builder.append(QueryTokens.expression(ctx.FETCH())); - builder.append(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 builder; - } + QueryRendererBuilder builder = QueryRenderer.builder(); + builder.appendExpression(nested); - @Override - public QueryTokenStream visitJoin_spec(JpqlParser.Join_specContext ctx) { + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); + } - QueryRendererBuilder builder = QueryRenderer.builder(); + if (ctx.identification_variable() != null) { + builder.appendExpression(visit(ctx.identification_variable())); + } - if (ctx.LEFT() != null) { - builder.append(QueryTokens.expression(ctx.LEFT())); - } - if (ctx.OUTER() != null) { - builder.append(QueryTokens.expression(ctx.OUTER())); - } - if (ctx.INNER() != null) { - builder.append(QueryTokens.expression(ctx.INNER())); - } - if (ctx.JOIN() != null) { - builder.append(QueryTokens.expression(ctx.JOIN())); + return builder; } - return builder; - } - - @Override - public QueryTokenStream visitJoin_condition(JpqlParser.Join_conditionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.ON())); - builder.appendExpression(visit(ctx.conditional_expression())); - - return builder; + return super.visitIdentificationVariableDeclarationOrCollectionMemberDeclaration(ctx); } @Override @@ -252,23 +148,25 @@ public QueryTokenStream visitJoin_association_path_expression( builder.appendExpression(visit(ctx.join_single_valued_path_expression())); } } else { + QueryRendererBuilder nested = QueryRenderer.builder(); + if (ctx.join_collection_valued_path_expression() != null) { - builder.append(QueryTokens.token(ctx.TREAT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.join_collection_valued_path_expression())); - builder.append(QueryTokens.expression(ctx.AS())); - builder.appendInline(visit(ctx.subtype())); - builder.append(TOKEN_CLOSE_PAREN); + nested.appendExpression(visit(ctx.join_collection_valued_path_expression())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.subtype())); + } else if (ctx.join_single_valued_path_expression() != null) { - builder.append(QueryTokens.token(ctx.TREAT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.join_single_valued_path_expression())); - builder.append(QueryTokens.expression(ctx.AS())); - builder.appendInline(visit(ctx.subtype())); - builder.append(TOKEN_CLOSE_PAREN); + nested.appendExpression(visit(ctx.join_single_valued_path_expression())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.subtype())); } + + builder.append(QueryTokens.token(ctx.TREAT())); + builder.append(TOKEN_OPEN_PAREN); + builder.appendInline(nested); + builder.append(TOKEN_CLOSE_PAREN); } return builder; @@ -278,9 +176,12 @@ public QueryTokenStream visitJoin_association_path_expression( public QueryTokenStream visitJoin_collection_valued_path_expression( JpqlParser.Join_collection_valued_path_expressionContext ctx) { - List items = new ArrayList<>(3 + ctx.single_valued_embeddable_object_field().size()); + List items = new ArrayList<>(2 + ctx.single_valued_embeddable_object_field().size()); + + if (ctx.identification_variable() != null) { + items.add(ctx.identification_variable()); + } - items.add(ctx.identification_variable()); items.addAll(ctx.single_valued_embeddable_object_field()); items.add(ctx.collection_valued_field()); @@ -291,9 +192,11 @@ public QueryTokenStream visitJoin_collection_valued_path_expression( public QueryTokenStream visitJoin_single_valued_path_expression( JpqlParser.Join_single_valued_path_expressionContext ctx) { - List items = new ArrayList<>(3 + ctx.single_valued_embeddable_object_field().size()); + List items = new ArrayList<>(2 + ctx.single_valued_embeddable_object_field().size()); + if (ctx.identification_variable() != null) { + items.add(ctx.identification_variable()); + } - items.add(ctx.identification_variable()); items.addAll(ctx.single_valued_embeddable_object_field()); items.add(ctx.single_valued_object_field()); @@ -303,18 +206,23 @@ public QueryTokenStream visitJoin_single_valued_path_expression( @Override public QueryTokenStream visitCollection_member_declaration(JpqlParser.Collection_member_declarationContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.IN())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.collection_valued_path_expression())); - builder.append(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) { builder.append(QueryTokens.expression(ctx.AS())); } - builder.appendExpression(visit(ctx.identification_variable())); + if (ctx.identification_variable() != null) { + builder.appendExpression(visit(ctx.identification_variable())); + } return builder; } @@ -331,7 +239,7 @@ public QueryTokenStream visitQualified_identification_variable( builder.append(QueryTokens.expression(ctx.ENTRY())); builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.identification_variable())); + builder.append(visit(ctx.identification_variable())); builder.append(TOKEN_CLOSE_PAREN); } @@ -385,18 +293,6 @@ public QueryTokenStream visitSingle_valued_path_expression(JpqlParser.Single_val return builder; } - @Override - public QueryTokenStream visitGeneral_identification_variable(JpqlParser.General_identification_variableContext ctx) { - - if (ctx.identification_variable() != null) { - return visit(ctx.identification_variable()); - } else if (ctx.map_field_identification_variable() != null) { - return visit(ctx.map_field_identification_variable()); - } - - return QueryTokenStream.empty(); - } - @Override public QueryTokenStream visitGeneral_subpath(JpqlParser.General_subpathContext ctx) { @@ -421,6 +317,7 @@ public QueryTokenStream visitSimple_subpath(JpqlParser.Simple_subpathContext ctx items.add(ctx.general_identification_variable()); items.addAll(ctx.single_valued_object_field()); + return QueryTokenStream.concat(items, this::visit, TOKEN_DOT); } @@ -428,12 +325,15 @@ public QueryTokenStream visitSimple_subpath(JpqlParser.Simple_subpathContext ctx public QueryTokenStream visitTreated_subpath(JpqlParser.Treated_subpathContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); + + nested.appendExpression(visit(ctx.general_subpath())); + nested.append(QueryTokens.expression(ctx.AS())); + nested.appendExpression(visit(ctx.subtype())); builder.append(QueryTokens.token(ctx.TREAT())); builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.general_subpath())); - builder.append(QueryTokens.expression(ctx.AS())); - builder.appendInline(visit(ctx.subtype())); + builder.appendInline(nested); builder.append(TOKEN_CLOSE_PAREN); return builder; @@ -451,18 +351,6 @@ public QueryTokenStream visitState_field_path_expression(JpqlParser.State_field_ return builder; } - @Override - public QueryTokenStream visitState_valued_path_expression(JpqlParser.State_valued_path_expressionContext ctx) { - - if (ctx.state_field_path_expression() != null) { - return visit(ctx.state_field_path_expression()); - } else if (ctx.general_identification_variable() != null) { - return visit(ctx.general_identification_variable()); - } - - return QueryTokenStream.empty(); - } - @Override public QueryTokenStream visitSingle_valued_object_path_expression( JpqlParser.Single_valued_object_path_expressionContext ctx) { @@ -516,7 +404,7 @@ public QueryTokenStream visitUpdate_item(JpqlParser.Update_itemContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - List items = new ArrayList<>(2 + ctx.single_valued_embeddable_object_field().size()); + List items = new ArrayList<>(3 + ctx.single_valued_embeddable_object_field().size()); if (ctx.identification_variable() != null) { items.add(ctx.identification_variable()); @@ -532,45 +420,22 @@ public QueryTokenStream visitUpdate_item(JpqlParser.Update_itemContext ctx) { builder.appendInline(QueryTokenStream.concat(items, this::visit, TOKEN_DOT)); builder.append(TOKEN_EQUALS); - builder.appendInline(visit(ctx.new_value())); + builder.append(visit(ctx.new_value())); return builder; } @Override - public QueryTokenStream 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 QueryRenderer.from(QueryTokens.expression(ctx.NULL())); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitDelete_clause(JpqlParser.Delete_clauseContext ctx) { + public QueryTokenStream visitSelect_clause(JpqlParser.Select_clauseContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder builder = prepareSelectClause(ctx); - builder.append(QueryTokens.expression(ctx.DELETE())); - builder.append(QueryTokens.expression(ctx.FROM())); - builder.appendExpression(visit(ctx.entity_name())); - if (ctx.AS() != null) { - builder.append(QueryTokens.expression(ctx.AS())); - } - if (ctx.identification_variable() != null) { - builder.appendExpression(visit(ctx.identification_variable())); - } + builder.appendExpression(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA)); return builder; } - @Override - public QueryTokenStream visitSelect_clause(JpqlParser.Select_clauseContext ctx) { + QueryRendererBuilder prepareSelectClause(JpqlParser.Select_clauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); @@ -580,61 +445,25 @@ public QueryTokenStream visitSelect_clause(JpqlParser.Select_clauseContext ctx) builder.append(QueryTokens.expression(ctx.DISTINCT())); } - builder.append(QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA)); - - return builder; - } - - @Override - public QueryTokenStream visitSelect_item(JpqlParser.Select_itemContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.select_expression())); - - if (ctx.AS() != null || ctx.result_variable() != null) { - - if (ctx.AS() != null) { - builder.append(QueryTokens.expression(ctx.AS())); - } - - if (ctx.result_variable() != null) { - builder.appendExpression(visit(ctx.result_variable())); - } - } - return builder; } @Override public QueryTokenStream 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) { - - if (ctx.OBJECT() == null) { - return visit(ctx.identification_variable()); - } else { + if (ctx.identification_variable() != null && ctx.OBJECT() != null) { - QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.OBJECT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.identification_variable())); - builder.append(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 builder; - } - } else if (ctx.constructor_expression() != null) { - return visit(ctx.constructor_expression()); - } else { - return QueryTokenStream.empty(); + return builder; } + + return super.visitSelect_expression(ctx); } @Override @@ -651,24 +480,6 @@ public QueryTokenStream visitConstructor_expression(JpqlParser.Constructor_expre return builder; } - @Override - public QueryTokenStream visitConstructor_item(JpqlParser.Constructor_itemContext 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) { - return visit(ctx.identification_variable()); - } else if (ctx.literal() != null) { - return visit(ctx.literal()); - } - - return QueryTokenStream.empty(); - } - @Override public QueryTokenStream visitAggregate_expression(JpqlParser.Aggregate_expressionContext ctx) { @@ -695,7 +506,7 @@ public QueryTokenStream visitAggregate_expression(JpqlParser.Aggregate_expressio builder.append(QueryTokens.expression(ctx.DISTINCT())); } - builder.appendInline(visit(ctx.state_valued_path_expression())); + builder.appendInline(visit(ctx.simple_select_expression())); builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.COUNT() != null) { @@ -704,13 +515,8 @@ public QueryTokenStream visitAggregate_expression(JpqlParser.Aggregate_expressio if (ctx.DISTINCT() != null) { builder.append(QueryTokens.expression(ctx.DISTINCT())); } - if (ctx.identification_variable() != null) { - builder.appendInline(visit(ctx.identification_variable())); - } else if (ctx.state_valued_path_expression() != null) { - builder.appendInline(visit(ctx.state_valued_path_expression())); - } else if (ctx.single_valued_object_path_expression() != null) { - builder.appendInline(visit(ctx.single_valued_object_path_expression())); - } + + builder.appendInline(visit(ctx.simple_select_expression())); builder.append(TOKEN_CLOSE_PAREN); } else if (ctx.function_invocation() != null) { builder.append(visit(ctx.function_invocation())); @@ -719,17 +525,6 @@ public QueryTokenStream visitAggregate_expression(JpqlParser.Aggregate_expressio return builder; } - @Override - public QueryTokenStream visitWhere_clause(JpqlParser.Where_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.WHERE())); - builder.appendExpression(visit(ctx.conditional_expression())); - - return builder; - } - @Override public QueryTokenStream visitGroupby_clause(JpqlParser.Groupby_clauseContext ctx) { @@ -743,518 +538,88 @@ public QueryTokenStream visitGroupby_clause(JpqlParser.Groupby_clauseContext ctx } @Override - public QueryTokenStream visitGroupby_item(JpqlParser.Groupby_itemContext ctx) { + public QueryTokenStream visitOrderby_clause(JpqlParser.Orderby_clauseContext ctx) { - if (ctx.single_valued_path_expression() != null) { - return visit(ctx.single_valued_path_expression()); - } else if (ctx.identification_variable() != null) { - return visit(ctx.identification_variable()); - } + QueryRendererBuilder builder = QueryRenderer.builder(); - return QueryTokenStream.empty(); + builder.append(QueryTokens.expression(ctx.ORDER())); + builder.append(QueryTokens.expression(ctx.BY())); + builder.append(QueryTokenStream.concat(ctx.orderby_item(), this::visit, TOKEN_COMMA)); + + return builder; } @Override - public QueryTokenStream visitHaving_clause(JpqlParser.Having_clauseContext ctx) { + public QueryTokenStream visitSubquery_from_clause(JpqlParser.Subquery_from_clauseContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.HAVING())); - builder.appendExpression(visit(ctx.conditional_expression())); + builder.append(QueryTokens.expression(ctx.FROM())); + builder.appendExpression( + QueryTokenStream.concat(ctx.subselect_identification_variable_declaration(), this::visit, TOKEN_COMMA)); return builder; } @Override - public QueryTokenStream visitOrderby_clause(JpqlParser.Orderby_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + public QueryTokenStream visitConditional_primary(JpqlParser.Conditional_primaryContext ctx) { - builder.append(QueryTokens.expression(ctx.ORDER())); - builder.append(QueryTokens.expression(ctx.BY())); - builder.appendExpression(QueryTokenStream.concat(ctx.orderby_item(), this::visit, TOKEN_COMMA)); + if (ctx.conditional_expression() != null) { + return QueryTokenStream.group(visit(ctx.conditional_expression())); + } - return builder; + return super.visitConditional_primary(ctx); } @Override - public QueryTokenStream visitOrderby_item(JpqlParser.Orderby_itemContext ctx) { + public QueryTokenStream visitIn_expression(JpqlParser.In_expressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.state_field_path_expression() != null) { - builder.appendExpression(visit(ctx.state_field_path_expression())); - } else if (ctx.general_identification_variable() != null) { - builder.appendExpression(visit(ctx.general_identification_variable())); - } else if (ctx.result_variable() != null) { - builder.appendExpression(visit(ctx.result_variable())); + if (ctx.string_expression() != null) { + builder.appendExpression(visit(ctx.string_expression())); + } + + if (ctx.type_discriminator() != null) { + builder.appendExpression(visit(ctx.type_discriminator())); } - if (ctx.ASC() != null) { - builder.append(QueryTokens.expression(ctx.ASC())); + if (ctx.NOT() != null) { + builder.append(QueryTokens.expression(ctx.NOT())); } - if (ctx.DESC() != null) { - builder.append(QueryTokens.expression(ctx.DESC())); + + if (ctx.IN() != null) { + builder.append(QueryTokens.expression(ctx.IN())); } - if (ctx.nullsPrecedence() != null) { - builder.append(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 builder; } @Override - public QueryTokenStream visitNullsPrecedence(NullsPrecedenceContext ctx) { + public QueryTokenStream visitExists_expression(JpqlParser.Exists_expressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(TOKEN_NULLS); - - if (ctx.FIRST() != null) { - builder.append(TOKEN_FIRST); - } else if (ctx.LAST() != null) { - builder.append(TOKEN_LAST); + if (ctx.NOT() != null) { + builder.append(QueryTokens.expression(ctx.NOT())); } + builder.append(QueryTokens.expression(ctx.EXISTS())); + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); + return builder; } @Override - public QueryTokenStream visitSubquery(JpqlParser.SubqueryContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.simple_select_clause())); - builder.appendExpression(visit(ctx.subquery_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 visitSubquery_from_clause(JpqlParser.Subquery_from_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.FROM())); - builder.appendExpression( - QueryTokenStream.concat(ctx.subselect_identification_variable_declaration(), this::visit, TOKEN_COMMA)); - - return builder; - } - - @Override - public QueryTokenStream visitSubselect_identification_variable_declaration( - JpqlParser.Subselect_identification_variable_declarationContext ctx) { - return super.visitSubselect_identification_variable_declaration(ctx); - } - - @Override - public QueryTokenStream visitDerived_path_expression(JpqlParser.Derived_path_expressionContext ctx) { - return super.visitDerived_path_expression(ctx); - } - - @Override - public QueryTokenStream visitGeneral_derived_path(JpqlParser.General_derived_pathContext ctx) { - return super.visitGeneral_derived_path(ctx); - } - - @Override - public QueryTokenStream visitSimple_derived_path(JpqlParser.Simple_derived_pathContext ctx) { - return super.visitSimple_derived_path(ctx); - } - - @Override - public QueryTokenStream visitTreated_derived_path(JpqlParser.Treated_derived_pathContext ctx) { - return super.visitTreated_derived_path(ctx); - } - - @Override - public QueryTokenStream visitDerived_collection_member_declaration( - JpqlParser.Derived_collection_member_declarationContext ctx) { - return super.visitDerived_collection_member_declaration(ctx); - } - - @Override - public QueryTokenStream visitSimple_select_clause(JpqlParser.Simple_select_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.SELECT())); - if (ctx.DISTINCT() != null) { - builder.append(QueryTokens.expression(ctx.DISTINCT())); - } - builder.appendExpression(visit(ctx.simple_select_expression())); - - return builder; - } - - @Override - public QueryTokenStream visitSimple_select_expression(JpqlParser.Simple_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) { - return visit(ctx.identification_variable()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitScalar_expression(JpqlParser.Scalar_expressionContext ctx) { - - if (ctx.arithmetic_expression() != null) { - return visit(ctx.arithmetic_expression()); - } else if (ctx.string_expression() != null) { - return visit(ctx.string_expression()); - } else if (ctx.enum_expression() != null) { - return visit(ctx.enum_expression()); - } else if (ctx.datetime_expression() != null) { - return visit(ctx.datetime_expression()); - } else if (ctx.boolean_expression() != null) { - return visit(ctx.boolean_expression()); - } else if (ctx.case_expression() != null) { - return visit(ctx.case_expression()); - } else if (ctx.entity_type_expression() != null) { - return visit(ctx.entity_type_expression()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitConditional_expression(JpqlParser.Conditional_expressionContext ctx) { - - if (ctx.conditional_expression() != null) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.conditional_expression())); - builder.append(QueryTokens.expression(ctx.OR())); - builder.appendExpression(visit(ctx.conditional_term())); - - return builder; - } else { - return visit(ctx.conditional_term()); - } - } - - @Override - public QueryTokenStream visitConditional_term(JpqlParser.Conditional_termContext ctx) { - - if (ctx.conditional_term() != null) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.conditional_term())); - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.conditional_factor())); - - return builder; - } else { - return visit(ctx.conditional_factor()); - } - } - - @Override - public QueryTokenStream visitConditional_factor(JpqlParser.Conditional_factorContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(visit(ctx.conditional_primary())); - - return builder; - } - - @Override - public QueryTokenStream visitConditional_primary(JpqlParser.Conditional_primaryContext ctx) { - - if (ctx.simple_cond_expression() != null) { - return visit(ctx.simple_cond_expression()); - } - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.conditional_expression() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.conditional_expression())); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitSimple_cond_expression(JpqlParser.Simple_cond_expressionContext ctx) { - - if (ctx.comparison_expression() != null) { - return visit(ctx.comparison_expression()); - } else if (ctx.between_expression() != null) { - return visit(ctx.between_expression()); - } else if (ctx.in_expression() != null) { - return visit(ctx.in_expression()); - } else if (ctx.like_expression() != null) { - return visit(ctx.like_expression()); - } else if (ctx.null_comparison_expression() != null) { - return visit(ctx.null_comparison_expression()); - } else if (ctx.empty_collection_comparison_expression() != null) { - return visit(ctx.empty_collection_comparison_expression()); - } else if (ctx.collection_member_expression() != null) { - return visit(ctx.collection_member_expression()); - } else if (ctx.exists_expression() != null) { - return visit(ctx.exists_expression()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitBetween_expression(JpqlParser.Between_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.arithmetic_expression(0) != null) { - - builder.appendExpression(visit(ctx.arithmetic_expression(0))); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(QueryTokens.expression(ctx.BETWEEN())); - builder.appendExpression(visit(ctx.arithmetic_expression(1))); - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.arithmetic_expression(2))); - - } else if (ctx.string_expression(0) != null) { - - builder.appendExpression(visit(ctx.string_expression(0))); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(QueryTokens.expression(ctx.BETWEEN())); - builder.appendExpression(visit(ctx.string_expression(1))); - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.string_expression(2))); - - } else if (ctx.datetime_expression(0) != null) { - - builder.appendExpression(visit(ctx.datetime_expression(0))); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(QueryTokens.expression(ctx.BETWEEN())); - builder.appendExpression(visit(ctx.datetime_expression(1))); - builder.append(QueryTokens.expression(ctx.AND())); - builder.appendExpression(visit(ctx.datetime_expression(2))); - } - - return builder; - } - - @Override - public QueryTokenStream visitIn_expression(JpqlParser.In_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.string_expression() != null) { - builder.appendExpression(visit(ctx.string_expression())); - } - if (ctx.type_discriminator() != null) { - builder.appendExpression(visit(ctx.type_discriminator())); - } - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - if (ctx.IN() != null) { - builder.append(QueryTokens.expression(ctx.IN())); - } - - if (ctx.in_item() != null && !ctx.in_item().isEmpty()) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(QueryTokenStream.concat(ctx.in_item(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.collection_valued_input_parameter() != null) { - builder.append(visit(ctx.collection_valued_input_parameter())); - } - - return builder; - } - - @Override - public QueryTokenStream visitIn_item(JpqlParser.In_itemContext ctx) { - - if (ctx.literal() != null) { - return visit(ctx.literal()); - } else if (ctx.string_expression() != null) { - return visit(ctx.string_expression()); - } else if (ctx.boolean_literal() != null) { - return visit(ctx.boolean_literal()); - } else if (ctx.numeric_literal() != null) { - return visit(ctx.numeric_literal()); - } else if (ctx.date_time_timestamp_literal() != null) { - return visit(ctx.date_time_timestamp_literal()); - } else if (ctx.single_valued_input_parameter() != null) { - return visit(ctx.single_valued_input_parameter()); - } else if (ctx.conditional_expression() != null) { - return visit(ctx.conditional_expression()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitLike_expression(JpqlParser.Like_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.string_expression())); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - builder.append(QueryTokens.expression(ctx.LIKE())); - builder.appendExpression(visit(ctx.pattern_value())); - - if (ctx.ESCAPE() != null) { - - builder.append(QueryTokens.expression(ctx.ESCAPE())); - builder.appendExpression(visit(ctx.escape_character())); - } - - return builder; - } - - @Override - public QueryTokenStream visitNull_comparison_expression(JpqlParser.Null_comparison_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.single_valued_path_expression() != null) { - builder.appendExpression(visit(ctx.single_valued_path_expression())); - } else if (ctx.input_parameter() != null) { - builder.appendExpression(visit(ctx.input_parameter())); - } - - builder.append(QueryTokens.expression(ctx.IS())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - builder.append(QueryTokens.expression(ctx.NULL())); - - return builder; - } - - @Override - public QueryTokenStream visitEmpty_collection_comparison_expression( - JpqlParser.Empty_collection_comparison_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.collection_valued_path_expression())); - builder.append(QueryTokens.expression(ctx.IS())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - builder.append(QueryTokens.expression(ctx.EMPTY())); - - return builder; - } - - @Override - public QueryTokenStream visitCollection_member_expression(JpqlParser.Collection_member_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.entity_or_value_expression())); - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - builder.append(QueryTokens.expression(ctx.MEMBER())); - if (ctx.OF() != null) { - builder.append(QueryTokens.expression(ctx.OF())); - } - builder.append(visit(ctx.collection_valued_path_expression())); - - return builder; - } - - @Override - public QueryTokenStream visitEntity_or_value_expression(JpqlParser.Entity_or_value_expressionContext ctx) { - - if (ctx.single_valued_object_path_expression() != null) { - return visit(ctx.single_valued_object_path_expression()); - } else if (ctx.state_field_path_expression() != null) { - return visit(ctx.state_field_path_expression()); - } else if (ctx.simple_entity_or_value_expression() != null) { - return visit(ctx.simple_entity_or_value_expression()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitSimple_entity_or_value_expression( - JpqlParser.Simple_entity_or_value_expressionContext ctx) { - - if (ctx.identification_variable() != null) { - return visit(ctx.identification_variable()); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } else if (ctx.literal() != null) { - return visit(ctx.literal()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitExists_expression(JpqlParser.Exists_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.NOT() != null) { - builder.append(QueryTokens.expression(ctx.NOT())); - } - - builder.append(QueryTokens.expression(ctx.EXISTS())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitAll_or_any_expression(JpqlParser.All_or_any_expressionContext ctx) { + public QueryTokenStream visitAll_or_any_expression(JpqlParser.All_or_any_expressionContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); @@ -1266,180 +631,7 @@ public QueryTokenStream visitAll_or_any_expression(JpqlParser.All_or_any_express builder.append(QueryTokens.expression(ctx.SOME())); } - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitStringComparison(JpqlParser.StringComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.string_expression(0))); - builder.append(visit(ctx.comparison_operator())); - - if (ctx.string_expression(1) != null) { - builder.append(visit(ctx.string_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitBooleanComparison(JpqlParser.BooleanComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.boolean_expression(0))); - builder.append(QueryTokens.ventilated(ctx.op)); - - if (ctx.boolean_expression(1) != null) { - builder.append(visit(ctx.boolean_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitDirectBooleanCheck(JpqlParser.DirectBooleanCheckContext ctx) { - return visit(ctx.boolean_expression()); - } - - @Override - public QueryTokenStream visitEnumComparison(JpqlParser.EnumComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.enum_expression(0))); - builder.append(QueryTokens.ventilated(ctx.op)); - - if (ctx.enum_expression(1) != null) { - builder.append(visit(ctx.enum_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitDatetimeComparison(JpqlParser.DatetimeComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.datetime_expression(0))); - builder.append(QueryTokens.ventilated(ctx.comparison_operator().op)); - - if (ctx.datetime_expression(1) != null) { - builder.append(visit(ctx.datetime_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitEntityComparison(JpqlParser.EntityComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.entity_expression(0))); - builder.append(QueryTokens.expression(ctx.op)); - - if (ctx.entity_expression(1) != null) { - builder.append(visit(ctx.entity_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitArithmeticComparison(JpqlParser.ArithmeticComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(visit(ctx.arithmetic_expression(0))); - builder.append(visit(ctx.comparison_operator())); - - if (ctx.arithmetic_expression(1) != null) { - builder.append(visit(ctx.arithmetic_expression(1))); - } else { - builder.append(visit(ctx.all_or_any_expression())); - } - - return builder; - } - - @Override - public QueryTokenStream visitEntityTypeComparison(JpqlParser.EntityTypeComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendInline(visit(ctx.entity_type_expression(0))); - builder.append(QueryTokens.ventilated(ctx.op)); - builder.append(visit(ctx.entity_type_expression(1))); - - return builder; - } - - @Override - public QueryTokenStream visitRegexpComparison(JpqlParser.RegexpComparisonContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.appendExpression(visit(ctx.string_expression())); - builder.append(QueryTokens.expression(ctx.REGEXP())); - builder.appendExpression(visit(ctx.string_literal())); - - return builder; - } - - @Override - public QueryTokenStream visitComparison_operator(JpqlParser.Comparison_operatorContext ctx) { - return QueryRendererBuilder.from(QueryTokens.ventilated(ctx.op)); - } - - @Override - public QueryTokenStream visitArithmetic_expression(JpqlParser.Arithmetic_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.arithmetic_expression() != null) { - - builder.append(visit(ctx.arithmetic_expression())); - builder.append(QueryTokens.expression(ctx.op)); - builder.append(visit(ctx.arithmetic_term())); - - } else { - builder.append(visit(ctx.arithmetic_term())); - } - - return builder; - } - - @Override - public QueryTokenStream visitArithmetic_term(JpqlParser.Arithmetic_termContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.arithmetic_term() != null) { - - builder.appendInline(visit(ctx.arithmetic_term())); - builder.append(QueryTokens.ventilated(ctx.op)); - builder.append(visit(ctx.arithmetic_factor())); - } else { - builder.append(visit(ctx.arithmetic_factor())); - } + builder.append(QueryTokenStream.group(visit(ctx.subquery()))); return builder; } @@ -1452,7 +644,8 @@ public QueryTokenStream visitArithmetic_factor(JpqlParser.Arithmetic_factorConte if (ctx.op != null) { builder.append(QueryTokens.token(ctx.op)); } - builder.appendInline(visit(ctx.arithmetic_primary())); + + builder.append(visit(ctx.arithmetic_primary())); return builder; } @@ -1460,39 +653,13 @@ public QueryTokenStream visitArithmetic_factor(JpqlParser.Arithmetic_factorConte @Override public QueryTokenStream visitArithmetic_primary(JpqlParser.Arithmetic_primaryContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.numeric_literal() != null) { - builder.append(visit(ctx.numeric_literal())); - } else if (ctx.arithmetic_expression() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression())); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_numerics() != null) { - builder.append(visit(ctx.functions_returning_numerics())); - } else if (ctx.aggregate_expression() != null) { - builder.append(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.arithmetic_cast_function() != null) { - builder.append(visit(ctx.arithmetic_cast_function())); - } else if (ctx.type_cast_function() != null) { - builder.append(visit(ctx.type_cast_function())); - } else if (ctx.function_invocation() != null) { - builder.append(visit(ctx.function_invocation())); + if (ctx.arithmetic_expression() != null) { + return QueryTokenStream.group(visit(ctx.arithmetic_expression())); } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.group(visit(ctx.subquery())); } - return builder; + return super.visitArithmetic_primary(ctx); } @Override @@ -1500,149 +667,41 @@ public QueryTokenStream visitString_expression(JpqlParser.String_expressionConte QueryRendererBuilder builder = QueryRenderer.builder(); - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.string_literal() != null) { - builder.append(visit(ctx.string_literal())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_strings() != null) { - builder.append(visit(ctx.functions_returning_strings())); - } else if (ctx.aggregate_expression() != null) { - builder.append(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.string_cast_function() != null) { - builder.append(visit(ctx.string_cast_function())); - } else if (ctx.type_cast_function() != null) { - builder.append(visit(ctx.type_cast_function())); - } else if (ctx.function_invocation() != null) { - builder.append(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return builder; + return super.visitString_expression(ctx); } @Override public QueryTokenStream visitDatetime_expression(JpqlParser.Datetime_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.functions_returning_datetime() != null) { - builder.append(visit(ctx.functions_returning_datetime())); - } else if (ctx.aggregate_expression() != null) { - builder.append(visit(ctx.aggregate_expression())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - builder.append(visit(ctx.function_invocation())); - } else if (ctx.date_time_timestamp_literal() != null) { - builder.append(visit(ctx.date_time_timestamp_literal())); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return builder; + return super.visitDatetime_expression(ctx); } @Override public QueryTokenStream visitBoolean_expression(JpqlParser.Boolean_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.boolean_literal() != null) { - builder.append(visit(ctx.boolean_literal())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.function_invocation() != null) { - builder.append(visit(ctx.function_invocation())); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return builder; + return super.visitBoolean_expression(ctx); } @Override public QueryTokenStream visitEnum_expression(JpqlParser.Enum_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.state_valued_path_expression() != null) { - builder.append(visit(ctx.state_valued_path_expression())); - } else if (ctx.enum_literal() != null) { - builder.append(visit(ctx.enum_literal())); - } else if (ctx.input_parameter() != null) { - builder.append(visit(ctx.input_parameter())); - } else if (ctx.case_expression() != null) { - builder.append(visit(ctx.case_expression())); - } else if (ctx.subquery() != null) { - - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.subquery())); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitEntity_expression(JpqlParser.Entity_expressionContext ctx) { - - if (ctx.single_valued_object_path_expression() != null) { - return visit(ctx.single_valued_object_path_expression()); - } else if (ctx.simple_entity_expression() != null) { - return visit(ctx.simple_entity_expression()); + if (ctx.subquery() != null) { + return QueryTokenStream.group(visit(ctx.subquery())); } - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitSimple_entity_expression(JpqlParser.Simple_entity_expressionContext ctx) { - - if (ctx.identification_variable() != null) { - return visit(ctx.identification_variable()); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitEntity_type_expression(JpqlParser.Entity_type_expressionContext ctx) { - - if (ctx.type_discriminator() != null) { - return visit(ctx.type_discriminator()); - } else if (ctx.entity_type_literal() != null) { - return visit(ctx.entity_type_literal()); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } - - return QueryTokenStream.empty(); + return super.visitEnum_expression(ctx); } @Override @@ -1650,19 +709,15 @@ public QueryTokenStream visitType_discriminator(JpqlParser.Type_discriminatorCon QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.TYPE())); - builder.append(TOKEN_OPEN_PAREN); - if (ctx.general_identification_variable() != null) { - builder.appendInline(visit(ctx.general_identification_variable())); + builder.append(visit(ctx.general_identification_variable())); } else if (ctx.single_valued_object_path_expression() != null) { - builder.appendInline(visit(ctx.single_valued_object_path_expression())); + builder.append(visit(ctx.single_valued_object_path_expression())); } else if (ctx.input_parameter() != null) { - builder.appendInline(visit(ctx.input_parameter())); + builder.append(visit(ctx.input_parameter())); } - builder.append(TOKEN_CLOSE_PAREN); - return builder; + return QueryTokenStream.ofFunction(ctx.TYPE(), builder); } @Override @@ -1671,128 +726,60 @@ public QueryTokenStream visitFunctions_returning_numerics(JpqlParser.Functions_r QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.LENGTH() != null) { - - builder.append(QueryTokens.token(ctx.LENGTH())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.string_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.LENGTH(), visit(ctx.string_expression(0))); } else if (ctx.LOCATE() != null) { - builder.append(QueryTokens.token(ctx.LOCATE())); - builder.append(TOKEN_OPEN_PAREN); builder.appendInline(visit(ctx.string_expression(0))); builder.append(TOKEN_COMMA); builder.appendInline(visit(ctx.string_expression(1))); + if (ctx.arithmetic_expression() != null) { builder.append(TOKEN_COMMA); builder.appendInline(visit(ctx.arithmetic_expression(0))); } - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.ABS() != null) { - builder.append(QueryTokens.token(ctx.ABS())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + 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) { - - builder.append(QueryTokens.token(ctx.CEILING())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.CEILING(), visit(ctx.arithmetic_expression(0))); } else if (ctx.EXP() != null) { - - builder.append(QueryTokens.token(ctx.EXP())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.EXP(), visit(ctx.arithmetic_expression(0))); } else if (ctx.FLOOR() != null) { - - builder.append(QueryTokens.token(ctx.FLOOR())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.FLOOR(), visit(ctx.arithmetic_expression(0))); } else if (ctx.LN() != null) { - - builder.append(QueryTokens.token(ctx.LN())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.LN(), visit(ctx.arithmetic_expression(0))); } else if (ctx.SIGN() != null) { - - builder.append(QueryTokens.token(ctx.SIGN())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.SIGN(), visit(ctx.arithmetic_expression(0))); } else if (ctx.SQRT() != null) { - - builder.append(QueryTokens.token(ctx.SQRT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.arithmetic_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.SQRT(), visit(ctx.arithmetic_expression(0))); } else if (ctx.MOD() != null) { - builder.append(QueryTokens.token(ctx.MOD())); - builder.append(TOKEN_OPEN_PAREN); builder.appendInline(visit(ctx.arithmetic_expression(0))); builder.append(TOKEN_COMMA); builder.appendInline(visit(ctx.arithmetic_expression(1))); - builder.append(TOKEN_CLOSE_PAREN); + + return QueryTokenStream.ofFunction(ctx.MOD(), builder); } else if (ctx.POWER() != null) { - builder.append(QueryTokens.token(ctx.POWER())); - builder.append(TOKEN_OPEN_PAREN); builder.appendInline(visit(ctx.arithmetic_expression(0))); builder.append(TOKEN_COMMA); builder.appendInline(visit(ctx.arithmetic_expression(1))); - builder.append(TOKEN_CLOSE_PAREN); + + return QueryTokenStream.ofFunction(ctx.POWER(), builder); } else if (ctx.ROUND() != null) { - builder.append(QueryTokens.token(ctx.ROUND())); - builder.append(TOKEN_OPEN_PAREN); builder.appendInline(visit(ctx.arithmetic_expression(0))); builder.append(TOKEN_COMMA); builder.appendInline(visit(ctx.arithmetic_expression(1))); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.SIZE() != null) { - builder.append(QueryTokens.token(ctx.SIZE())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.collection_valued_path_expression())); - builder.append(TOKEN_CLOSE_PAREN); + 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) { - - builder.append(QueryTokens.token(ctx.INDEX())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.identification_variable())); - builder.append(TOKEN_CLOSE_PAREN); - } - - return builder; - } - - @Override - public QueryTokenStream visitFunctions_returning_datetime(JpqlParser.Functions_returning_datetimeContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - if (ctx.CURRENT_DATE() != null) { - builder.append(QueryTokens.expression(ctx.CURRENT_DATE())); - } else if (ctx.CURRENT_TIME() != null) { - builder.append(QueryTokens.expression(ctx.CURRENT_TIME())); - } else if (ctx.CURRENT_TIMESTAMP() != null) { - builder.append(QueryTokens.expression(ctx.CURRENT_TIMESTAMP())); - } else if (ctx.LOCAL() != null) { - - builder.append(QueryTokens.expression(ctx.LOCAL())); - - if (ctx.DATE() != null) { - builder.append(QueryTokens.expression(ctx.DATE())); - } else if (ctx.TIME() != null) { - builder.append(QueryTokens.expression(ctx.TIME())); - } else if (ctx.DATETIME() != null) { - builder.append(QueryTokens.expression(ctx.DATETIME())); - } + return QueryTokenStream.ofFunction(ctx.INDEX(), visit(ctx.identification_variable())); + } else if (ctx.extract_datetime_field() != null) { + builder.append(visit(ctx.extract_datetime_field())); } return builder; @@ -1804,22 +791,17 @@ public QueryTokenStream visitFunctions_returning_strings(JpqlParser.Functions_re QueryRendererBuilder builder = QueryRenderer.builder(); if (ctx.CONCAT() != null) { - - builder.append(QueryTokens.token(ctx.CONCAT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); + return QueryTokenStream.ofFunction(ctx.CONCAT(), + QueryTokenStream.concat(ctx.string_expression(), this::visit, TOKEN_COMMA)); } else if (ctx.SUBSTRING() != null) { - builder.append(QueryTokens.token(ctx.SUBSTRING())); - builder.append(TOKEN_OPEN_PAREN); builder.append(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); builder.appendInline(QueryTokenStream.concat(ctx.arithmetic_expression(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); + + return QueryTokenStream.ofFunction(ctx.SUBSTRING(), builder); } else if (ctx.TRIM() != null) { - builder.append(QueryTokens.token(ctx.TRIM())); - builder.append(TOKEN_OPEN_PAREN); if (ctx.trim_specification() != null) { builder.appendExpression(visit(ctx.trim_specification())); } @@ -1829,35 +811,36 @@ public QueryTokenStream visitFunctions_returning_strings(JpqlParser.Functions_re if (ctx.FROM() != null) { builder.append(QueryTokens.expression(ctx.FROM())); } + builder.append(visit(ctx.string_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); - } else if (ctx.LOWER() != null) { - builder.append(QueryTokens.token(ctx.LOWER())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.string_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); + 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) { - builder.append(QueryTokens.token(ctx.UPPER())); - builder.append(TOKEN_OPEN_PAREN); builder.append(visit(ctx.string_expression(0))); - builder.append(TOKEN_CLOSE_PAREN); - } + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.arithmetic_expression(0))); - return builder; - } + return QueryTokenStream.ofFunction(ctx.LEFT(), builder); + } else if (ctx.RIGHT() != null) { - @Override - public QueryTokenStream visitTrim_specification(JpqlParser.Trim_specificationContext ctx) { + builder.appendInline(visit(ctx.string_expression(0))); + builder.append(TOKEN_COMMA); + builder.append(visit(ctx.arithmetic_expression(0))); - if (ctx.LEADING() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.LEADING())); - } else if (ctx.TRAILING() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.TRAILING())); - } else { - return QueryRenderer.from(QueryTokens.expression(ctx.BOTH())); + 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 builder; } @Override @@ -1865,16 +848,13 @@ public QueryTokenStream visitArithmetic_cast_function(JpqlParser.Arithmetic_cast QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.CAST())); - builder.append(TOKEN_OPEN_PAREN); builder.appendExpression(visit(ctx.string_expression())); if (ctx.AS() != null) { builder.append(QueryTokens.expression(ctx.AS())); } builder.append(QueryTokens.token(ctx.f)); - builder.append(TOKEN_CLOSE_PAREN); - return builder; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override @@ -1882,12 +862,12 @@ public QueryTokenStream visitType_cast_function(JpqlParser.Type_cast_functionCon QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.CAST())); - builder.append(TOKEN_OPEN_PAREN); builder.appendExpression(visit(ctx.scalar_expression())); + if (ctx.AS() != null) { builder.append(QueryTokens.expression(ctx.AS())); } + builder.appendInline(visit(ctx.identification_variable())); if (!CollectionUtils.isEmpty(ctx.numeric_literal())) { @@ -1896,9 +876,8 @@ public QueryTokenStream visitType_cast_function(JpqlParser.Type_cast_functionCon builder.appendInline(QueryTokenStream.concat(ctx.numeric_literal(), this::visit, TOKEN_COMMA)); builder.append(TOKEN_CLOSE_PAREN); } - builder.append(TOKEN_CLOSE_PAREN); - return builder; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override @@ -1906,14 +885,13 @@ public QueryTokenStream visitString_cast_function(JpqlParser.String_cast_functio QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.token(ctx.CAST())); - builder.append(TOKEN_OPEN_PAREN); builder.appendExpression(visit(ctx.scalar_expression())); - builder.append(QueryTokens.expression(ctx.AS())); + if (ctx.AS() != null) { + builder.append(QueryTokens.expression(ctx.AS())); + } builder.append(QueryTokens.token(ctx.STRING())); - builder.append(TOKEN_CLOSE_PAREN); - return builder; + return QueryTokenStream.ofFunction(ctx.CAST(), builder); } @Override @@ -1936,16 +914,13 @@ public QueryTokenStream visitFunction_invocation(JpqlParser.Function_invocationC @Override public QueryTokenStream visitExtract_datetime_field(JpqlParser.Extract_datetime_fieldContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.EXTRACT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendExpression(visit(ctx.datetime_field())); - builder.append(QueryTokens.expression(ctx.FROM())); - builder.appendInline(visit(ctx.datetime_expression())); - builder.append(TOKEN_CLOSE_PAREN); + nested.appendExpression(visit(ctx.datetime_field())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.datetime_expression())); - return builder; + return QueryTokenStream.ofFunction(ctx.EXTRACT(), nested); } @Override @@ -1956,195 +931,27 @@ public QueryTokenStream visitDatetime_field(JpqlParser.Datetime_fieldContext ctx @Override public QueryTokenStream visitExtract_datetime_part(JpqlParser.Extract_datetime_partContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.EXTRACT())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendExpression(visit(ctx.datetime_part())); - builder.append(QueryTokens.expression(ctx.FROM())); - builder.append(visit(ctx.datetime_expression())); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitDatetime_part(JpqlParser.Datetime_partContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream 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 QueryTokenStream 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()); - } - } - - @Override - public QueryTokenStream visitGeneral_case_expression(JpqlParser.General_case_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.CASE())); - builder.appendExpression(QueryTokenStream.concat(ctx.when_clause(), this::visit, TOKEN_SPACE)); - - builder.append(QueryTokens.expression(ctx.ELSE())); - builder.appendExpression(visit(ctx.scalar_expression())); - builder.append(QueryTokens.expression(ctx.END())); - - return builder; - } - - @Override - public QueryTokenStream visitWhen_clause(JpqlParser.When_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.WHEN())); - builder.appendExpression(visit(ctx.conditional_expression())); - builder.append(QueryTokens.expression(ctx.THEN())); - builder.appendExpression(visit(ctx.scalar_expression())); - - return builder; - } - - @Override - public QueryTokenStream visitSimple_case_expression(JpqlParser.Simple_case_expressionContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.CASE())); - builder.appendExpression(visit(ctx.case_operand())); - builder.appendExpression(QueryTokenStream.concat(ctx.simple_when_clause(), this::visit, TOKEN_SPACE)); - - builder.append(QueryTokens.expression(ctx.ELSE())); - builder.appendExpression(visit(ctx.scalar_expression())); - builder.append(QueryTokens.expression(ctx.END())); - - return builder; - } - - @Override - public QueryTokenStream 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 QueryTokenStream visitSimple_when_clause(JpqlParser.Simple_when_clauseContext ctx) { - - QueryRendererBuilder builder = QueryRenderer.builder(); + QueryRendererBuilder nested = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.WHEN())); - builder.appendExpression(visit(ctx.scalar_expression(0))); - builder.append(QueryTokens.expression(ctx.THEN())); - builder.appendExpression(visit(ctx.scalar_expression(1))); + nested.appendExpression(visit(ctx.datetime_part())); + nested.append(QueryTokens.expression(ctx.FROM())); + nested.appendExpression(visit(ctx.datetime_expression())); - return builder; + return QueryTokenStream.ofFunction(ctx.EXTRACT(), nested); } @Override public QueryTokenStream visitCoalesce_expression(JpqlParser.Coalesce_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.token(ctx.COALESCE())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; + return QueryTokenStream.ofFunction(ctx.COALESCE(), + QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); } @Override public QueryTokenStream visitNullif_expression(JpqlParser.Nullif_expressionContext ctx) { - QueryRendererBuilder builder = QueryRenderer.builder(); - - builder.append(QueryTokens.expression(ctx.NULLIF())); - builder.append(TOKEN_OPEN_PAREN); - builder.appendInline(visit(ctx.scalar_expression(0))); - builder.append(TOKEN_COMMA); - builder.appendInline(visit(ctx.scalar_expression(1))); - builder.append(TOKEN_CLOSE_PAREN); - - return builder; - } - - @Override - public QueryTokenStream visitTrim_character(JpqlParser.Trim_characterContext ctx) { - - if (ctx.CHARACTER() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.CHARACTER())); - } else if (ctx.character_valued_input_parameter() != null) { - return visit(ctx.character_valued_input_parameter()); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitIdentification_variable(JpqlParser.Identification_variableContext ctx) { - - if (ctx.IDENTIFICATION_VARIABLE() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.IDENTIFICATION_VARIABLE())); - } else if (ctx.f != null) { - return QueryRenderer.from(QueryTokens.token(ctx.f)); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitConstructor_name(JpqlParser.Constructor_nameContext ctx) { - return visit(ctx.entity_name()); - } - - @Override - public QueryTokenStream visitLiteral(JpqlParser.LiteralContext ctx) { - - if (ctx.STRINGLITERAL() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.STRINGLITERAL())); - } else if (ctx.JAVASTRINGLITERAL() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.JAVASTRINGLITERAL())); - } else if (ctx.INTLITERAL() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.INTLITERAL())); - } else if (ctx.FLOATLITERAL() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.FLOATLITERAL())); - } else if (ctx.LONGLITERAL() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.LONGLITERAL())); - } else if (ctx.boolean_literal() != null) { - return visit(ctx.boolean_literal()); - } else if (ctx.entity_type_literal() != null) { - return visit(ctx.entity_type_literal()); - } - - return QueryTokenStream.empty(); + return QueryTokenStream.ofFunction(ctx.NULLIF(), + QueryTokenStream.concat(ctx.scalar_expression(), this::visit, TOKEN_COMMA)); } @Override @@ -2166,176 +973,24 @@ public QueryTokenStream visitInput_parameter(JpqlParser.Input_parameterContext c } @Override - public QueryTokenStream visitPattern_value(JpqlParser.Pattern_valueContext ctx) { - return visit(ctx.string_expression()); - } - - @Override - public QueryTokenStream visitDate_time_timestamp_literal(JpqlParser.Date_time_timestamp_literalContext ctx) { - return QueryRenderer.from(QueryTokens.expression(ctx.STRINGLITERAL())); - } - - @Override - public QueryTokenStream visitEntity_type_literal(JpqlParser.Entity_type_literalContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitEscape_character(JpqlParser.Escape_characterContext ctx) { - - if (ctx.CHARACTER() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.CHARACTER())); - } else if (ctx.character_valued_input_parameter() != null) { - return visit(ctx.character_valued_input_parameter()); - } else if (ctx.string_literal() != null) { - return visit(ctx.string_literal()); - } - - return QueryTokenStream.empty(); - } - - @Override - public QueryTokenStream visitNumeric_literal(JpqlParser.Numeric_literalContext ctx) { - - if (ctx.INTLITERAL() != null) { - return QueryRenderer.from(QueryTokens.token(ctx.INTLITERAL())); - } else if (ctx.FLOATLITERAL() != null) { - return QueryRenderer.from(QueryTokens.token(ctx.FLOATLITERAL())); - } else if (ctx.LONGLITERAL() != null) { - return QueryRenderer.from(QueryTokens.token(ctx.LONGLITERAL())); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitBoolean_literal(JpqlParser.Boolean_literalContext ctx) { - - if (ctx.TRUE() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.TRUE())); - } else if (ctx.FALSE() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.FALSE())); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitEnum_literal(JpqlParser.Enum_literalContext ctx) { - return visit(ctx.state_field_path_expression()); - } - - @Override - public QueryTokenStream visitString_literal(JpqlParser.String_literalContext ctx) { - - if (ctx.CHARACTER() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.CHARACTER())); - } else if (ctx.STRINGLITERAL() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.STRINGLITERAL())); - } else { - return QueryTokenStream.empty(); - } - } - - @Override - public QueryTokenStream visitSingle_valued_embeddable_object_field( - JpqlParser.Single_valued_embeddable_object_fieldContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitSubtype(JpqlParser.SubtypeContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitCollection_valued_field(JpqlParser.Collection_valued_fieldContext ctx) { - if (ctx.reserved_word() != null) { - return visit(ctx.reserved_word()); - } - return visit(ctx.identification_variable()); + public QueryTokenStream visitEntity_name(JpqlParser.Entity_nameContext ctx) { + return QueryTokenStream.concat(ctx.reserved_word(), this::visit, TOKEN_DOT); } @Override - public QueryTokenStream visitSingle_valued_object_field(JpqlParser.Single_valued_object_fieldContext ctx) { - - if (ctx.reserved_word() != null) { - return visit(ctx.reserved_word()); - } - return visit(ctx.identification_variable()); - } + public QueryTokenStream visitChildren(RuleNode node) { - @Override - public QueryTokenStream visitState_field(JpqlParser.State_fieldContext ctx) { + int childCount = node.getChildCount(); - if (ctx.reserved_word() != null) { - return visit(ctx.reserved_word()); + if (childCount == 1 && node.getChild(0) instanceof RuleContext t) { + return visit(t); } - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitCollection_value_field(JpqlParser.Collection_value_fieldContext ctx) { - if (ctx.reserved_word() != null) { - return visit(ctx.reserved_word()); + if (childCount == 1 && node.getChild(0) instanceof TerminalNode t) { + return QueryTokens.token(t); } - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitEntity_name(JpqlParser.Entity_nameContext ctx) { - return QueryTokenStream.concat(ctx.reserved_word(), this::visitReserved_word, TOKEN_DOT); - } - - @Override - public QueryTokenStream visitResult_variable(JpqlParser.Result_variableContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitSuperquery_identification_variable( - JpqlParser.Superquery_identification_variableContext ctx) { - return visit(ctx.identification_variable()); - } - - @Override - public QueryTokenStream visitCollection_valued_input_parameter( - JpqlParser.Collection_valued_input_parameterContext ctx) { - return visit(ctx.input_parameter()); - } - - @Override - public QueryTokenStream visitSingle_valued_input_parameter(JpqlParser.Single_valued_input_parameterContext ctx) { - return visit(ctx.input_parameter()); - } - - @Override - public QueryTokenStream visitFunction_name(JpqlParser.Function_nameContext ctx) { - return visit(ctx.string_literal()); - } - - @Override - public QueryTokenStream visitCharacter_valued_input_parameter( - JpqlParser.Character_valued_input_parameterContext ctx) { - if (ctx.CHARACTER() != null) { - return QueryRenderer.from(QueryTokens.expression(ctx.CHARACTER())); - } else if (ctx.input_parameter() != null) { - return visit(ctx.input_parameter()); - } else { - return QueryTokenStream.empty(); - } + return QueryTokenStream.concatExpressions(node, this::visit); } - @Override - public QueryTokenStream visitReserved_word(Reserved_wordContext ctx) { - if (ctx.IDENTIFICATION_VARIABLE() != null) { - return QueryRenderer.from(QueryTokens.token(ctx.IDENTIFICATION_VARIABLE())); - } else if (ctx.f != null) { - return QueryRenderer.from(QueryTokens.token(ctx.f)); - } else { - return QueryTokenStream.empty(); - } - } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java index 41d0661d2c..9a86eb4424 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/JpqlSortedQueryTransformer.java @@ -19,10 +19,11 @@ 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.lang.Nullable; import org.springframework.util.Assert; /** @@ -52,7 +53,7 @@ class JpqlSortedQueryTransformer extends JpqlQueryRenderer { } @Override - public QueryTokenStream visitSelect_statement(JpqlParser.Select_statementContext ctx) { + public QueryTokenStream visitSelectQuery(JpqlParser.SelectQueryContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); @@ -71,56 +72,61 @@ public QueryTokenStream visitSelect_statement(JpqlParser.Select_statementContext builder.appendExpression(visit(ctx.having_clause())); } - doVisitOrderBy(builder, ctx); + if (ctx.set_fuction() != null) { + builder.appendExpression(visit(ctx.set_fuction())); + } else { + doVisitOrderBy(builder, ctx.orderby_clause()); + } return builder; } @Override - public QueryTokenStream visitSelect_clause(JpqlParser.Select_clauseContext ctx) { - - if (dtoDelegate == null) { - return super.visitSelect_clause(ctx); - } + public QueryTokenStream visitFromQuery(JpqlParser.FromQueryContext ctx) { QueryRendererBuilder builder = QueryRenderer.builder(); - builder.append(QueryTokens.expression(ctx.SELECT())); + builder.appendExpression(visit(ctx.from_clause())); - if (ctx.DISTINCT() != null) { - builder.append(QueryTokens.expression(ctx.DISTINCT())); + if (ctx.where_clause() != null) { + builder.appendExpression(visit(ctx.where_clause())); } - QueryTokenStream tokenStream = QueryTokenStream.concat(ctx.select_item(), this::visit, TOKEN_COMMA); - - return builder.append(dtoDelegate.transformSelectionList(tokenStream)); - } + if (ctx.groupby_clause() != null) { + builder.appendExpression(visit(ctx.groupby_clause())); + } - private void doVisitOrderBy(QueryRendererBuilder builder, JpqlParser.Select_statementContext ctx) { + if (ctx.having_clause() != null) { + builder.appendExpression(visit(ctx.having_clause())); + } - if (ctx.orderby_clause() != null) { - QueryTokenStream existingOrder = visit(ctx.orderby_clause()); - if (sort.isSorted()) { - builder.appendInline(existingOrder); - } else { - builder.append(existingOrder); - } + if (ctx.set_fuction() != null) { + builder.appendExpression(visit(ctx.set_fuction())); + } else { + doVisitOrderBy(builder, ctx.orderby_clause()); } - if (sort.isSorted()) { + return builder; + } - List sortBy = transformerSupport.orderBy(primaryFromAlias, sort); + @Override + public QueryTokenStream visitSelect_clause(JpqlParser.Select_clauseContext ctx) { - if (ctx.orderby_clause() != null) { + if (dtoDelegate == null) { + return super.visitSelect_clause(ctx); + } - QueryRendererBuilder extension = QueryRenderer.builder().append(TOKEN_COMMA).append(sortBy); + QueryRendererBuilder builder = prepareSelectClause(ctx); - builder.appendInline(extension); - } else { - builder.append(TOKEN_ORDER_BY); - builder.append(sortBy); - } + 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 @@ -129,21 +135,60 @@ public QueryTokenStream visitSelect_item(JpqlParser.Select_itemContext ctx) { QueryTokenStream tokens = super.visitSelect_item(ctx); if (ctx.result_variable() != null && !tokens.isEmpty()) { - transformerSupport.registerAlias(tokens.getLast()); + transformerSupport.registerAlias(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 (!tokens.isEmpty()) { - transformerSupport.registerAlias(tokens.getLast()); + 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 cea64d91ad..a80de6e4a3 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/KeysetScrollDelegate.java @@ -22,12 +22,13 @@ import java.util.List; import java.util.Map; +import org.jspecify.annotations.Nullable; + import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.ScrollPosition.Direction; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; import org.springframework.data.jpa.repository.support.JpaEntityInformation; -import org.springframework.lang.Nullable; /** * Delegate for keyset scrolling. @@ -69,8 +70,7 @@ public static Collection getProjectionInputProperties(JpaEntityInformati return properties; } - @Nullable - public P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStrategy strategy) { + public @Nullable P createPredicate(KeysetScrollPosition keyset, Sort sort, QueryStrategy strategy) { Map keysetValues = keyset.getKeys(); @@ -104,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++; } @@ -134,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. */ @@ -184,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. @@ -204,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. @@ -212,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 40aa051983..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 @@ -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 53790bcf4f..a7e8dc35e6 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/Meta.java @@ -19,8 +19,9 @@ import java.util.LinkedHashMap; import java.util.Map; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; + +import org.jspecify.annotations.Nullable; import org.springframework.util.StringUtils; /** @@ -69,8 +70,7 @@ public void setComment(String comment) { /** * @return {@literal null} if not set. */ - @Nullable - public String getComment() { + public @Nullable String getComment() { return getValue(MetaKey.COMMENT.key); } @@ -106,9 +106,8 @@ void setValue(String key, @Nullable Object value) { this.values.put(key, value); } - @Nullable @SuppressWarnings("unchecked") - private T 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 eeed1593fa..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 @@ -22,6 +22,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -33,7 +34,7 @@ import org.springframework.data.repository.query.ResultProcessor; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.util.Lazy; -import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; /** * Implementation of {@link RepositoryQuery} based on {@link jakarta.persistence.NamedQuery}s. @@ -55,14 +56,13 @@ final class NamedQuery extends AbstractJpaQuery { private final String countQueryName; private final @Nullable String countProjection; private final boolean namedCountQueryIsPresent; - private final Lazy declaredQuery; - private final QueryParameterSetter.QueryMetadataCache metadataCache; + private final Lazy entityQuery; private final QueryRewriter queryRewriter; /** * Creates a new {@link NamedQuery}. */ - private NamedQuery(JpaQueryMethod method, EntityManager em, QueryRewriter queryRewriter) { + private NamedQuery(JpaQueryMethod method, EntityManager em, JpaQueryConfiguration queryConfiguration) { super(method, em); @@ -70,19 +70,19 @@ private NamedQuery(JpaQueryMethod method, EntityManager em, QueryRewriter queryR this.countQueryName = method.getNamedCountQueryName(); QueryExtractor extractor = method.getQueryExtractor(); this.countProjection = method.getCountQueryProjection(); - this.queryRewriter = queryRewriter; + this.queryRewriter = queryConfiguration.getQueryRewriter(method); Parameters parameters = method.getParameters(); if (parameters.hasSortParameter()) { - throw new IllegalStateException(String.format("Query method %s is backed by a NamedQuery and must " + 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, method.isNativeQuery() ? "NativeQuery" : "Query")); + method.isNativeQuery() ? "NativeQuery" : "Query")); } this.namedCountQueryIsPresent = hasNamedQuery(em, countQueryName); - Query query = em.createNamedQuery(queryName); + Query namedQuery = em.createNamedQuery(queryName); boolean weNeedToCreateCountQuery = !namedCountQueryIsPresent && method.getParameters().hasLimitingParameters(); boolean cantExtractQuery = !extractor.canExtractQuery(); @@ -90,17 +90,40 @@ private NamedQuery(JpaQueryMethod method, EntityManager em, QueryRewriter queryR 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( "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, method.isNativeQuery() ? "NativeQuery" : "Query")); + method, nativeQuery ? "NativeQuery" : "Query")); } - String queryString = extractor.extractQueryString(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.declaredQuery = Lazy - .of(() -> DeclaredQuery.of(queryString, method.isNativeQuery() || query.toString().contains("NativeQuery"))); - this.metadataCache = new QueryParameterSetter.QueryMetadataCache(); + this.entityQuery = Lazy.of(() -> EntityQuery.create(declaredQuery, queryConfiguration.getSelector())); } /** @@ -133,10 +156,11 @@ static boolean hasNamedQuery(EntityManager em, String queryName) { * * @param method must not be {@literal null}. * @param em must not be {@literal null}. - * @param queryRewriter must not be {@literal null}. + * @param selector must not be {@literal null}. + * @param queryConfiguration must not be {@literal null}. */ - @Nullable - public static RepositoryQuery lookupFrom(JpaQueryMethod method, EntityManager em, QueryRewriter queryRewriter) { + public static @Nullable RepositoryQuery lookupFrom(JpaQueryMethod method, EntityManager em, + JpaQueryConfiguration queryConfiguration) { String queryName = method.getNamedQueryName(); @@ -154,13 +178,18 @@ public static RepositoryQuery lookupFrom(JpaQueryMethod method, EntityManager em method.isNativeQuery() ? "NativeQuery" : "Query")); } - RepositoryQuery query = new NamedQuery(method, em, queryRewriter); + RepositoryQuery query = new NamedQuery(method, em, queryConfiguration); if (LOG.isDebugEnabled()) { LOG.debug(String.format("Found named query '%s'", queryName)); } return query; } + @Override + public boolean hasDeclaredCountQuery() { + return namedCountQueryIsPresent; + } + @Override protected Query doCreateQuery(JpaParametersParameterAccessor accessor) { @@ -175,9 +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 @@ -186,26 +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.get().deriveCountQuery(countProjection).getQueryString(); + String countQueryString = entityQuery.get().deriveCountQuery(countProjection).getQueryString(); countQueryString = potentiallyRewriteQuery(countQueryString, accessor.getSort(), accessor.getPageable()); - cacheKey = countQueryString; countQuery = em.createQuery(countQueryString, Long.class); } - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata(cacheKey, countQuery); - - return parameterBinder.get().bind(countQuery, metadata, accessor); + return parameterBinder.get().bind(countQuery, accessor); } @Override - protected Class getTypeToRead(ReturnedType returnedType) { + protected @Nullable Class getTypeToRead(ReturnedType returnedType) { if (getQueryMethod().isNativeQuery()) { @@ -226,7 +247,7 @@ protected Class getTypeToRead(ReturnedType returnedType) { return type.isInterface() ? Tuple.class : null; } - return declaredQuery.get().hasConstructorExpression() // + return entityQuery.get().hasConstructorExpression() // ? null // : super.getTypeToRead(returnedType); } @@ -240,9 +261,9 @@ protected Class getTypeToRead(ReturnedType returnedType) { * @param pageable * @return */ - private String potentiallyRewriteQuery(String originalQuery, Sort sort, Pageable pageable) { + private String potentiallyRewriteQuery(String originalQuery, Sort sort, @Nullable Pageable pageable) { - return pageable.isPaged() // + return pageable != null && pageable.isPaged() // ? queryRewriter.rewrite(originalQuery, pageable) // : queryRewriter.rewrite(originalQuery, sort); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java index 9221cc3807..35045c5e25 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/NativeJpaQuery.java @@ -19,16 +19,15 @@ import jakarta.persistence.Query; import jakarta.persistence.Tuple; +import org.jspecify.annotations.Nullable; + import org.springframework.core.annotation.MergedAnnotation; import org.springframework.core.annotation.MergedAnnotations; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.NativeQuery; -import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.repository.query.RepositoryQuery; import org.springframework.data.repository.query.ReturnedType; -import org.springframework.data.repository.query.ValueExpressionDelegate; -import org.springframework.lang.Nullable; import org.springframework.util.ObjectUtils; /** @@ -42,7 +41,7 @@ * @author Mark Paluch * @author Greg Turnquist */ -final class NativeJpaQuery extends AbstractStringBasedJpaQuery { +class NativeJpaQuery extends AbstractStringBasedJpaQuery { private final @Nullable String sqlResultSetMapping; @@ -55,26 +54,47 @@ final class NativeJpaQuery extends AbstractStringBasedJpaQuery { * @param em must not be {@literal null}. * @param queryString must not be {@literal null} or empty. * @param countQueryString must not be {@literal null} or empty. - * @param rewriter the query rewriter to use. - * @param valueExpressionDelegate must not be {@literal null}. + * @param queryConfiguration must not be {@literal null}. */ - public NativeJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, @Nullable String countQueryString, - QueryRewriter rewriter, ValueExpressionDelegate valueExpressionDelegate) { + NativeJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, @Nullable String countQueryString, + JpaQueryConfiguration queryConfiguration) { - super(method, em, queryString, countQueryString, rewriter, valueExpressionDelegate); + super(method, em, queryString, countQueryString, queryConfiguration); MergedAnnotations annotations = MergedAnnotations.from(method.getMethod()); MergedAnnotation annotation = annotations.get(NativeQuery.class); + this.sqlResultSetMapping = annotation.isPresent() ? annotation.getString("sqlResultSetMapping") : null; + this.queryForEntity = getQueryMethod().isQueryForEntity(); + } + + /** + * Creates a new {@link NativeJpaQuery} encapsulating the query annotated on the given {@link JpaQueryMethod}. + * + * @param method must not be {@literal null}. + * @param em must not be {@literal null}. + * @param query must not be {@literal null} . + * @param countQuery can be {@literal null} if not defined. + * @param queryConfiguration must not be {@literal null}. + */ + public NativeJpaQuery(JpaQueryMethod method, EntityManager em, DeclaredQuery query, + @Nullable DeclaredQuery countQuery, JpaQueryConfiguration queryConfiguration) { + + super(method, em, query, countQuery, queryConfiguration); + MergedAnnotations annotations = MergedAnnotations.from(method.getMethod()); + MergedAnnotation annotation = annotations.get(NativeQuery.class); + + this.sqlResultSetMapping = annotation.isPresent() ? annotation.getString("sqlResultSetMapping") : null; this.queryForEntity = getQueryMethod().isQueryForEntity(); } @Override - protected Query createJpaQuery(String queryString, Sort sort, Pageable pageable, ReturnedType returnedType) { + protected Query createJpaQuery(QueryProvider declaredQuery, Sort sort, @Nullable Pageable pageable, + ReturnedType returnedType) { EntityManager em = getEntityManager(); - String query = potentiallyRewriteQuery(queryString, sort, pageable); + String query = potentiallyRewriteQuery(declaredQuery.getQueryString(), sort, pageable); if (!ObjectUtils.isEmpty(sqlResultSetMapping)) { return em.createNativeQuery(query, sqlResultSetMapping); @@ -84,8 +104,7 @@ protected Query createJpaQuery(String queryString, Sort sort, Pageable pageable, return type == null ? em.createNativeQuery(query) : em.createNativeQuery(query, type); } - @Nullable - private Class getTypeToQueryFor(ReturnedType returnedType) { + private @Nullable Class getTypeToQueryFor(ReturnedType returnedType) { Class result = queryForEntity ? returnedType.getDomainType() : null; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java index 7a49f584a1..8c7c458852 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinder.java @@ -21,6 +21,7 @@ import org.springframework.data.jpa.repository.query.QueryParameterSetter.ErrorHandling; import org.springframework.data.jpa.support.PageableUtils; import org.springframework.util.Assert; +import org.springframework.util.ErrorHandler; /** * {@link ParameterBinder} is used to bind method parameters to a {@link Query}. This is usually done whenever an @@ -33,7 +34,7 @@ * @author Jens Schauder * @author Yanming Zhou */ -public class ParameterBinder { +class ParameterBinder { static final String PARAMETER_NEEDS_TO_BE_NAMED = "For queries with named parameters you need to provide names for method parameters; Use @Param for query method parameters, or when on Java 8+ use the javac flag -parameters"; @@ -72,18 +73,18 @@ public ParameterBinder(JpaParameters parameters, Iterable 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); } } @@ -91,13 +92,12 @@ public void bind(QueryParameterSetter.BindableQuery query, JpaParametersParamete * Binds the parameters to the given query and applies special parameter types (e.g. pagination). * * @param query must not be {@literal null}. - * @param metadata must not be {@literal null}. * @param accessor must not be {@literal null}. */ - Query bindAndPrepare(Query query, QueryParameterSetter.QueryMetadata metadata, + Query bindAndPrepare(Query query, JpaParametersParameterAccessor accessor) { - bind(query, metadata, accessor); + bind(query, accessor); Pageable pageable = accessor.getPageable(); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java index 21a715e07f..00aef26195 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ParameterBinderFactory.java @@ -23,7 +23,6 @@ import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier; import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata; import org.springframework.util.Assert; /** @@ -40,37 +39,37 @@ class ParameterBinderFactory { * otherwise. * * @param parameters method parameters that are available for binding, must not be {@literal null}. + * @param preferNamedParameters * @return a {@link ParameterBinder} that can assign values for the method parameters to query parameters of a * {@link jakarta.persistence.Query} */ - static ParameterBinder createBinder(JpaParameters parameters) { + static ParameterBinder createBinder(JpaParameters parameters, boolean preferNamedParameters) { Assert.notNull(parameters, "JpaParameters must not be null"); - QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters); + QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters, preferNamedParameters); List 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,13 +78,13 @@ static ParameterBinder createCriteriaBinder(JpaParameters parameters, List bindings = query.getParameterBindings(); QueryParameterSetterFactory expressionSetterFactory = QueryParameterSetterFactory.parsing(parser, evaluationContextProvider); - QueryParameterSetterFactory basicSetterFactory = QueryParameterSetterFactory.basic(parameters); + QueryParameterSetterFactory basicSetterFactory = QueryParameterSetterFactory.basic(parameters, + query.hasNamedParameter()); + + boolean usesPaging = query instanceof EntityQuery eq && eq.usesPaging(); - return new ParameterBinder(parameters, createSetters(bindings, query, expressionSetterFactory, basicSetterFactory), - !query.usesPaging()); + // TODO: lets maybe obtain the bindable query and pass that on to create the setters? + return new ParameterBinder(parameters, createSetters(query.getParameterBindings(), query, expressionSetterFactory, basicSetterFactory), + !usesPaging); } - private static List 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 e5cffccaf6..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 @@ -21,13 +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; @@ -36,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; @@ -68,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. @@ -143,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; } @@ -186,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. @@ -202,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; @@ -264,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; } @@ -326,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("%")) { @@ -349,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}. @@ -423,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 { @@ -441,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 { @@ -455,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() + "]"; @@ -483,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() + "]"; @@ -495,7 +687,7 @@ public String toString() { * @author Mark Paluch * @since 3.1.2 */ - sealed interface ParameterOrigin permits Expression,MethodInvocationArgument { + public sealed interface ParameterOrigin permits Expression, MethodInvocationArgument, Synthetic { /** * Creates a {@link Expression} for the given {@code expression}. @@ -507,6 +699,17 @@ static Expression ofExpression(ValueExpression expression) { return new Expression(expression); } + /** + * Creates a {@link Expression} for the given {@code expression} string. + * + * @param value the captured value. + * @param source source from which this value is derived. + * @return {@link Synthetic} for the given {@code value}. + */ + static Synthetic synthetic(@Nullable Object value, Object source) { + return new Synthetic(value, source); + } + /** * Creates a {@link MethodInvocationArgument} object for {@code name} * @@ -532,13 +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}. * @@ -568,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(); } /** @@ -588,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; + } } /** @@ -608,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 aa96a30163..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 @@ -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 @@ -56,107 +56,132 @@ */ public class ParameterMetadataProvider { - private final CriteriaBuilder builder; + static final Object PLACEHOLDER = new Object(); + 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,126 +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. */ - public 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.NEGATING_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/PartTreeJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PartTreeJpaQuery.java index a1246ac056..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 @@ -16,14 +16,16 @@ 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 java.util.concurrent.locks.ReentrantLock; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.data.domain.KeysetScrollPosition; import org.springframework.data.domain.OffsetScrollPosition; @@ -33,15 +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}. @@ -55,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}. @@ -90,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 @@ -121,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; @@ -146,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(); @@ -161,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) { @@ -208,57 +216,42 @@ private static boolean expectsCollection(Type type) { */ private class QueryPreparer { - private final @Nullable CriteriaQuery cachedCriteriaQuery; - private final ReentrantLock lock = new ReentrantLock(); - private final @Nullable ParameterBinder cachedParameterBinder; - private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache(); - - QueryPreparer(boolean recreateQueries) { - - JpaQueryCreator creator = createCreator(null); - - if (recreateQueries) { - this.cachedCriteriaQuery = null; - this.cachedParameterBinder = null; - } else { - this.cachedCriteriaQuery = creator.createQuery(); - this.cachedParameterBinder = getBinder(creator.getParameterExpressions()); - } - } + private final PartTreeQueryCache cache = new PartTreeQueryCache(); /** * Creates a new {@link Query} for the given parameter values. */ public Query createQuery(JpaParametersParameterAccessor accessor) { - CriteriaQuery criteriaQuery = cachedCriteriaQuery; - ParameterBinder parameterBinder = cachedParameterBinder; + Sort sort = getDynamicSort(accessor); + JpqlQueryCreator creator = createCreator(sort, accessor); + String jpql = creator.createQuery(sort); + Query query; - if (cachedCriteriaQuery == null || accessor.hasBindableNullValue()) { - JpaQueryCreator creator = createCreator(accessor); - criteriaQuery = creator.createQuery(getDynamicSort(accessor)); - List> expressions = creator.getParameterExpressions(); - parameterBinder = getBinder(expressions); + if (log.isDebugEnabled()) { + log.debug(String.format("%s: Derived query for query method [%s]: '%s'", getClass().getSimpleName(), + getQueryMethod(), jpql)); } - if (parameterBinder == null) { - throw new IllegalStateException("ParameterBinder is null"); + try { + query = creator.useTupleQuery() ? em.createQuery(jpql, Tuple.class) : em.createQuery(jpql); + } catch (Exception e) { + throw new BadJpqlGrammarException(e.getMessage(), jpql, e); } - TypedQuery query = createQuery(criteriaQuery); + ParameterBinder binder = creator.getBinder(); ScrollPosition scrollPosition = accessor.getParameters().hasScrollPositionParameter() ? accessor.getScrollPosition() : null; - return restrictMaxResultsIfNecessary(invokeBinding(parameterBinder, query, accessor, this.metadataCache), - scrollPosition); + return restrictMaxResultsIfNecessary(invokeBinding(binder, query, accessor), scrollPosition); } /** * Restricts the max results of the given {@link Query} if the current {@code tree} marks this {@code query} as * limited. */ - @SuppressWarnings("ConstantConditions") + @SuppressWarnings({ "ConstantConditions", "NullAway" }) private Query restrictMaxResultsIfNecessary(Query query, @Nullable ScrollPosition scrollPosition) { if (scrollPosition instanceof OffsetScrollPosition offset && !offset.isInitial()) { @@ -289,65 +282,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) { - - if (this.cachedCriteriaQuery != null) { - lock.lock(); - try { - return getEntityManager().createQuery(criteriaQuery); - } finally { - lock.unlock(); - } + protected JpqlQueryCreator createCreator(Sort sort, JpaParametersParameterAccessor accessor) { + + JpqlQueryCreator jpqlQueryCreator = cache.get(sort, accessor); // this caching thingy is broken due to IS NULL + // rendering for + if (jpqlQueryCreator != null) { + return jpqlQueryCreator; } - return getEntityManager().createQuery(criteriaQuery); + EntityManager entityManager = getEntityManager(); + ResultProcessor processor = getQueryMethod().getResultProcessor(); + + ParameterMetadataProvider provider = new ParameterMetadataProvider(accessor, escape, templates); + ReturnedType returnedType = processor.withDynamicProjection(accessor).getReturnedType(); + + if (accessor.getScrollPosition() instanceof KeysetScrollPosition keyset) { + return new JpaKeysetScrollQueryCreator(tree, returnedType, provider, templates, entityInformation.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()); + } + + 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) { @@ -366,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/StringQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java similarity index 64% rename from spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java index b36d7e728e..c4dd4a2ac5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StringQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/PreprocessedQuery.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.Set; import java.util.TreeSet; @@ -29,16 +30,13 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.jspecify.annotations.Nullable; + import org.springframework.data.expression.ValueExpression; import org.springframework.data.expression.ValueExpressionParser; import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier; -import org.springframework.data.jpa.repository.query.ParameterBinding.InParameterBinding; -import org.springframework.data.jpa.repository.query.ParameterBinding.LikeParameterBinding; -import org.springframework.data.jpa.repository.query.ParameterBinding.MethodInvocationArgument; -import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin; import org.springframework.data.repository.query.ValueExpressionQueryRewriter; -import org.springframework.data.repository.query.parser.Part.Type; -import org.springframework.lang.Nullable; +import org.springframework.data.repository.query.parser.Part; import org.springframework.util.Assert; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -46,230 +44,126 @@ import org.springframework.util.StringUtils; /** - * Encapsulation of a JPA query String. Offers access to parameters as bindings. The internal query String is cleaned - * from decorated parameters like {@literal %:lastname%} and the matching bindings take care of applying the decorations - * in the {@link ParameterBinding#prepare(Object)} method. Note that this class also handles replacing SpEL expressions - * with synthetic bind parameters. + * A pre-parsed query implementing {@link DeclaredQuery} providing information about parameter bindings. + *

+ * 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 Oliver Gierke - * @author Thomas Darimont - * @author Oliver Wehrens + * @author Christoph Strobl * @author Mark Paluch - * @author Jens Schauder - * @author Diego Krupitza - * @author Greg Turnquist - * @author Yuriy Tsarkov + * @since 4.0 */ -class StringQuery implements DeclaredQuery { +public final class PreprocessedQuery implements DeclaredQuery { - private final String query; + private final DeclaredQuery source; private final List bindings; - private final boolean containsPageableInSpel; private final boolean usesJdbcStyleParameters; - private final boolean isNative; - private final QueryEnhancer queryEnhancer; - private final boolean hasNamedParameters; - - /** - * Creates a new {@link StringQuery} from the given JPQL query. - * - * @param query must not be {@literal null} or empty. - */ - public StringQuery(String query, boolean isNative) { - this(query, isNative, it -> {}); + private final boolean containsPageableInSpel; + private final boolean hasNamedBindings; + + private PreprocessedQuery(DeclaredQuery query, List bindings, boolean usesJdbcStyleParameters, + boolean containsPageableInSpel) { + this.source = query; + this.bindings = bindings; + this.usesJdbcStyleParameters = usesJdbcStyleParameters; + this.containsPageableInSpel = containsPageableInSpel; + this.hasNamedBindings = containsNamedParameter(bindings); } - /** - * Creates a new {@link StringQuery} from the given JPQL query. - * - * @param query must not be {@literal null} or empty. - */ - private StringQuery(String query, boolean isNative, Consumer> parameterPostProcessor) { - - Assert.hasText(query, "Query must not be null or empty"); - - this.isNative = isNative; - this.bindings = new ArrayList<>(); - this.containsPageableInSpel = query.contains("#pageable"); - - Metadata queryMeta = new Metadata(); - this.query = ParameterBindingParser.INSTANCE.parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(query, - this.bindings, queryMeta); + private static boolean containsNamedParameter(List bindings) { - this.usesJdbcStyleParameters = queryMeta.usesJdbcStyleParameters; - this.queryEnhancer = QueryEnhancerFactory.forQuery(this); - - parameterPostProcessor.accept(this.bindings); - - boolean hasNamedParameters = false; - for (ParameterBinding parameterBinding : getParameterBindings()) { - if (parameterBinding.getIdentifier().hasName() && parameterBinding.getOrigin().isMethodArgument()) { - hasNamedParameters = true; - break; + for (ParameterBinding parameterBinding : bindings) { + if (parameterBinding.getIdentifier().hasName() && parameterBinding.getOrigin() + .isMethodArgument()) { + return true; } } - - this.hasNamedParameters = hasNamedParameters; + return false; } /** - * Returns whether we have found some like bindings. + * Parse a {@link DeclaredQuery query} into its parametrized form by identifying anonymous, named, indexed and SpEL + * parameters. Query parsing applies special treatment to {@code IN} and {@code LIKE} parameter bindings. + * + * @param declaredQuery the source query to parse. + * @return a parsed {@link PreprocessedQuery}. */ - boolean hasParameterBindings() { - return !bindings.isEmpty(); - } - - String getProjection() { - return this.queryEnhancer.getProjection(); - } - - @Override - public List getParameterBindings() { - return bindings; - } - - @Override - public DeclaredQuery deriveCountQuery(@Nullable String countQueryProjection) { - - // need to copy expression bindings from the declared to the derived query as JPQL query derivation only sees - // JPA parameter markers and not the original expressions anymore. - - return new StringQuery(this.queryEnhancer.createCountQueryFor(countQueryProjection), // - this.isNative, derivedBindings -> { - - // need to copy expression bindings from the declared to the derived query as JPQL query derivation only sees - // JPA - // parameter markers and not the original expressions anymore. - if (this.hasParameterBindings() && !this.getParameterBindings().equals(derivedBindings)) { - - for (ParameterBinding binding : bindings) { - - Predicate 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); - } - } - } + public static PreprocessedQuery parse(DeclaredQuery declaredQuery) { + return ParameterBindingParser.INSTANCE.parse(declaredQuery.getQueryString(), declaredQuery::rewrite, + parameterBindings -> { }); } - @Override - public boolean usesJdbcStyleParameters() { - return usesJdbcStyleParameters; - } - @Override public String getQueryString() { - return query; + return source.getQueryString(); } @Override - @Nullable - public String getAlias() { - return queryEnhancer.detectAlias(); + public boolean isNative() { + return source.isNative(); } - @Override - public boolean hasConstructorExpression() { - return queryEnhancer.hasConstructorExpression(); + boolean hasBindings() { + return !bindings.isEmpty(); } - @Override - public boolean isDefaultProjection() { - return getProjection().equalsIgnoreCase(getAlias()); + boolean hasNamedBindings() { + return this.hasNamedBindings; } - @Override - public boolean hasNamedParameter() { - return hasNamedParameters; + public boolean containsPageableInSpel() { + return containsPageableInSpel; } - @Override - public boolean usesPaging() { - return containsPageableInSpel; + boolean usesJdbcStyleParameters() { + return usesJdbcStyleParameters; } - @Override - public boolean isNativeQuery() { - return isNative; + public List getBindings() { + return Collections.unmodifiableList(bindings); } /** - * Value object to track and allocate used parameter index labels in a query. + * Derive a query (typically a count query) from the given query string. We need to copy expression bindings from the + * declared to the derived query as JPQL query derivation only sees JPA parameter markers and not the original + * expressions anymore. + * + * @return */ - static class IndexedParameterLabels { - - private final TreeSet 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."); - } + @Override + public PreprocessedQuery rewrite(String newQueryString) { - usedLabels.add(index); + return ParameterBindingParser.INSTANCE.parse(newQueryString, source::rewrite, derivedBindings -> { - return index; - } + // need to copy expression bindings from the declared to the derived query as JPQL query derivation only sees + // JPA parameter markers and not the original expressions anymore. + if (this.hasBindings() && !this.bindings.equals(derivedBindings)) { - private int attemptAllocate(int attempts) { + for (ParameterBinding binding : bindings) { - for (int i = 0; i < attempts; i++) { + Predicate identifier = binding::bindsTo; + Predicate notCompatible = Predicate.not(binding::isCompatibleWith); - if (usedLabels.contains(i + 1)) { - continue; + // replace incompatible bindings + if (derivedBindings.removeIf(it -> identifier.test(it) && notCompatible.test(it))) { + derivedBindings.add(binding); + } } - - return i + 1; } + }); + } - return -1; - } - - public boolean hasLabels() { - return !usedLabels.isEmpty(); - } + @Override + public String toString() { + return "ParametrizedQuery[" + source + ", " + bindings + ']'; } /** @@ -282,7 +176,7 @@ enum ParameterBindingParser { INSTANCE; private static final String EXPRESSION_PARAMETER_PREFIX = "__$synthetic$__"; - public static final String POSITIONAL_OR_INDEXED_PARAMETER = "\\?(\\d*+(?![#\\w]))"; + 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. @@ -293,7 +187,7 @@ enum ParameterBindingParser { private static final Pattern NAMED_STYLE_PARAM = Pattern.compile("(?!\\\\):\\w+"); // no \ and :[text] private static final String MESSAGE = "Already found parameter binding with same index / parameter name but differing binding type; " - + "Already have: %s, found %s; If you bind a parameter multiple times make sure they use the same binding"; + + "Already have: %s, found %s; If you bind a parameter multiple times make sure they use the same binding"; private static final int INDEXED_PARAMETER_GROUP = 4; private static final int NAMED_PARAMETER_GROUP = 6; private static final int COMPARISION_TYPE_GROUP = 1; @@ -331,12 +225,16 @@ enum ParameterBindingParser { * Parses {@link ParameterBinding} instances from the given query and adds them to the registered bindings. Returns * the cleaned up query. */ - String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String query, List bindings, - Metadata queryMeta) { + 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. */ @@ -366,15 +264,18 @@ String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String que Integer parameterIndex = getParameterIndex(parameterIndexString); String match = matcher.group(0); - if (JDBC_STYLE_PARAM.matcher(match).find()) { - queryMeta.usesJdbcStyleParameters = true; + 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()) { + if (NUMBERED_STYLE_PARAM.matcher(match) + .find() || NAMED_STYLE_PARAM.matcher(match).find()) { usesJpaStyleParameters = true; } - if (usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) { + if (usesJpaStyleParameters && jdbcStyle) { throw new IllegalArgumentException("Mixing of ? parameters and other forms like ?1 is not supported"); } @@ -390,60 +291,64 @@ String parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(String que parameterIndex = parameterLabels.allocate(); } - BindingIdentifier queryParameter; + ParameterBinding.BindingIdentifier queryParameter; if (parameterIndex != null) { - queryParameter = BindingIdentifier.of(parameterIndex); - } else { - queryParameter = BindingIdentifier.of(parameterName); + queryParameter = ParameterBinding.BindingIdentifier.of(parameterIndex); } - ParameterOrigin origin = ObjectUtils.isEmpty(expression) - ? ParameterOrigin.ofParameter(parameterName, parameterIndex) - : ParameterOrigin.ofExpression(expression); - - BindingIdentifier targetBinding = queryParameter; - Function bindingFactory; - switch (ParameterBindingType.of(typeSource)) { - - case LIKE: - - Type likeType = LikeParameterBinding.getLikeTypeFrom(matcher.group(2)); - bindingFactory = (identifier) -> new LikeParameterBinding(identifier, origin, likeType); - break; + else if (parameterName != null) { + queryParameter = ParameterBinding.BindingIdentifier.of(parameterName); + } + else { + throw new IllegalStateException("No bindable expression found"); + } + ParameterBinding.ParameterOrigin origin = ObjectUtils.isEmpty(expression) + ? ParameterBinding.ParameterOrigin.ofParameter(parameterName, parameterIndex) + : ParameterBinding.ParameterOrigin.ofExpression(expression); - case IN: - bindingFactory = (identifier) -> new InParameterBinding(identifier, origin); - break; + ParameterBinding.BindingIdentifier targetBinding = queryParameter; + Function bindingFactory = switch (ParameterBindingType + .of(typeSource)) { + case LIKE -> { - case AS_IS: // fall-through we don't need a special parameter queryParameter for the given parameter. - default: - bindingFactory = (identifier) -> new ParameterBinding(identifier, origin); - } + Part.Type likeType = ParameterBinding.LikeParameterBinding.getLikeTypeFrom(matcher.group(2)); + yield (identifier) -> new ParameterBinding.LikeParameterBinding(identifier, origin, likeType); + } + case IN -> + (identifier) -> new ParameterBinding.InParameterBinding(identifier, origin); // fall-through we + // don't need a special + // parameter queryParameter for the + // given parameter. + default -> (identifier) -> new ParameterBinding(identifier, origin); + }; if (origin.isExpression()) { parameterBindings.register(bindingFactory.apply(queryParameter)); - } else { + } + else { targetBinding = parameterBindings.register(queryParameter, origin, bindingFactory, parameterLabels); } replacement = targetBinding.hasName() ? ":" + targetBinding.getName() - : ((!usesJpaStyleParameters && queryMeta.usesJdbcStyleParameters) ? "?" - : "?" + targetBinding.getPosition()); + : ((!usesJpaStyleParameters && jdbcStyle) ? "?" : "?" + targetBinding.getPosition()); String result; String substring = matcher.group(2); int index = resultingQuery.indexOf(substring, currentIndex); if (index < 0) { result = resultingQuery; - } else { + } + else { currentIndex = index + replacement.length(); result = resultingQuery.substring(0, index) + replacement - + resultingQuery.substring(index + substring.length()); + + resultingQuery.substring(index + substring.length()); } resultingQuery = result; } - return resultingQuery; + parameterBindingPostProcessor.accept(bindings); + return new PreprocessedQuery(declaredQueryFactory.apply(resultingQuery), bindings, jdbcStyle, + containsPageableInSpel); } private static ValueExpressionQueryRewriter.ParsedQuery createSpelExtractor(String queryWithSpel, @@ -466,8 +371,7 @@ private static ValueExpressionQueryRewriter.ParsedQuery createSpelExtractor(Stri return rewriter.parse(queryWithSpel); } - @Nullable - private static Integer getParameterIndex(@Nullable String parameterIndexString) { + private static @Nullable Integer getParameterIndex(@Nullable String parameterIndexString) { if (parameterIndexString == null || parameterIndexString.isEmpty()) { return null; @@ -527,8 +431,7 @@ private enum ParameterBindingType { * * @return the keyword */ - @Nullable - public String getKeyword() { + public @Nullable String getKeyword() { return keyword; } @@ -553,18 +456,15 @@ static ParameterBindingType of(String typeSource) { } } - static class Metadata { - private boolean usesJdbcStyleParameters = false; - } - /** * Utility to create unique parameter bindings for LIKE that refer to the same underlying method parameter but are - * bound to potentially unique query parameters for {@link LikeParameterBinding#prepare(Object) LIKE rewrite}. + * bound to potentially unique query parameters for {@link ParameterBinding.LikeParameterBinding#prepare(Object) LIKE + * rewrite}. * * @author Mark Paluch * @since 3.1.2 */ - static class ParameterBindings { + private static class ParameterBindings { private final MultiValueMap methodArgumentToLikeBindings = new LinkedMultiValueMap<>(); @@ -580,21 +480,22 @@ public ParameterBindings(List bindings, Consumer bindingFactory, IndexedParameterLabels parameterLabels) { + ParameterBinding.BindingIdentifier register(ParameterBinding.BindingIdentifier identifier, + ParameterBinding.ParameterOrigin origin, + Function bindingFactory, + IndexedParameterLabels parameterLabels) { - Assert.isInstanceOf(MethodInvocationArgument.class, origin); + Assert.isInstanceOf(ParameterBinding.MethodInvocationArgument.class, origin); - BindingIdentifier methodArgument = ((MethodInvocationArgument) origin).identifier(); + ParameterBinding.BindingIdentifier methodArgument = ((ParameterBinding.MethodInvocationArgument) origin) + .identifier(); List bindingsForOrigin = getBindings(methodArgument); if (!isBound(identifier)) { @@ -614,7 +515,7 @@ BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin, } } - BindingIdentifier syntheticIdentifier; + ParameterBinding.BindingIdentifier syntheticIdentifier; if (identifier.hasName() && methodArgument.hasName()) { int index = 0; @@ -623,9 +524,10 @@ BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin, index++; newName = methodArgument.getName() + "_" + index; } - syntheticIdentifier = BindingIdentifier.of(newName); - } else { - syntheticIdentifier = BindingIdentifier.of(parameterLabels.allocate()); + syntheticIdentifier = ParameterBinding.BindingIdentifier.of(newName); + } + else { + syntheticIdentifier = ParameterBinding.BindingIdentifier.of(parameterLabels.allocate()); } ParameterBinding newBinding = bindingFactory.apply(syntheticIdentifier); @@ -635,11 +537,12 @@ BindingIdentifier register(BindingIdentifier identifier, ParameterOrigin origin, } private boolean existsBoundParameter(String key) { - return methodArgumentToLikeBindings.values().stream().flatMap(Collection::stream) + return methodArgumentToLikeBindings.values().stream() + .flatMap(Collection::stream) .anyMatch(it -> key.equals(it.getName())); } - private List getBindings(BindingIdentifier identifier) { + private List getBindings(ParameterBinding.BindingIdentifier identifier) { return methodArgumentToLikeBindings.computeIfAbsent(identifier, s -> new ArrayList<>()); } @@ -647,4 +550,79 @@ public void register(ParameterBinding parameterBinding) { registration.accept(parameterBinding); } } + + /** + * Value object to track and allocate used parameter index labels in a query. + */ + static class IndexedParameterLabels { + + private final TreeSet 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/ProcedureParameter.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ProcedureParameter.java index 0cef0b0a0f..a2f9546d59 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ProcedureParameter.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ProcedureParameter.java @@ -20,7 +20,7 @@ import java.util.Objects; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * This class represents a Stored Procedure Parameter and an instance of the annotation diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java index 88d4716d88..2810f957c0 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancer.java @@ -15,21 +15,33 @@ */ package org.springframework.data.jpa.repository.query; -import java.util.Set; +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.Sort; import org.springframework.data.repository.query.ReturnedType; -import org.springframework.lang.Nullable; /** * This interface describes the API for enhancing a given Query. * * @author Diego Krupitza * @author Greg Turnquist - * @since 2.7.0 + * @author Mark Paluch + * @since 2.7 */ public interface QueryEnhancer { + /** + * Creates a new {@link QueryEnhancer} for a {@link DeclaredQuery}. Convenience method for + * {@link QueryEnhancerFactory#create(QueryProvider)}. + * + * @param query the query to be enhanced. + * @return the new {@link QueryEnhancer}. + * @since 4.0 + */ + static QueryEnhancer create(DeclaredQuery query) { + return QueryEnhancerFactory.forQuery(query).create(query); + } + /** * Returns whether the given JPQL query contains a constructor expression. * @@ -38,9 +50,9 @@ public interface QueryEnhancer { boolean hasConstructorExpression(); /** - * Resolves the alias for the entity to be retrieved from the given JPA query. + * Resolves the primary alias for the entity to be retrieved from the given JPA query. * - * @return Might return {@literal null}. + * @return can return {@literal null}. */ @Nullable String detectAlias(); @@ -52,61 +64,24 @@ public interface QueryEnhancer { */ String getProjection(); - /** - * Returns the join aliases of the query. - * - * @return the join aliases of the query. - */ - @Deprecated(forRemoval = true) - Set getJoinAliases(); - /** * Gets the query we want to use for enhancements. * * @return non-null {@link DeclaredQuery} that wraps the query. */ - @Deprecated(forRemoval = true) - DeclaredQuery getQuery(); - - /** - * Adds {@literal order by} clause to the JPQL query. Uses the first alias to bind the sorting property to. - * - * @param sort the sort specification to apply. - * @return the modified query string. - */ - String applySorting(Sort sort); - - /** - * Adds {@literal order by} clause to the JPQL query. - * - * @param sort the sort specification to apply. - * @param alias the alias to be used in the order by clause. May be {@literal null} or empty. - * @return the modified query string. - * @deprecated since 3.5, use {@link #rewrite(QueryRewriteInformation)} instead. - */ - @Deprecated(since = "3.5", forRemoval = true) - String applySorting(Sort sort, @Nullable String alias); + QueryProvider getQuery(); /** * Rewrite the query to include sorting and apply {@link ReturnedType} customizations. * * @param rewriteInformation the rewrite information to apply. * @return the modified query string. - * @since 3.5 + * @since 4.0 */ String rewrite(QueryRewriteInformation rewriteInformation); /** - * Creates a count projected query from the given original query. - * - * @return Guaranteed to be not {@literal null}. - */ - default String createCountQueryFor() { - return createCountQueryFor(null); - } - - /** - * Creates a count projected query from the given original query using the provided countProjection. + * Creates a count projected query from the given original query using the provided {@code countProjection}. * * @param countProjection may be {@literal null}. * @return a query String to be used a count query for pagination. Guaranteed to be not {@literal null}. @@ -116,7 +91,7 @@ default String createCountQueryFor() { /** * Interface to describe the information needed to rewrite a query. * - * @since 3.5 + * @since 4.0 */ interface QueryRewriteInformation { @@ -129,6 +104,7 @@ interface QueryRewriteInformation { * @return type expected to be returned by the query. */ ReturnedType getReturnedType(); + } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactories.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactories.java new file mode 100644 index 0000000000..face0778a0 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactories.java @@ -0,0 +1,171 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; + +import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.util.ClassUtils; + +/** + * Pre-defined QueryEnhancerFactories to be used for query enhancement. + * + * @author Mark Paluch + * @since 4.0 + */ +public class QueryEnhancerFactories { + + private static final Log LOG = LogFactory.getLog(QueryEnhancerFactories.class); + + static final boolean jSqlParserPresent = ClassUtils.isPresent("net.sf.jsqlparser.parser.JSqlParser", + QueryEnhancerFactory.class.getClassLoader()); + + static { + + if (jSqlParserPresent) { + LOG.info("JSqlParser is in classpath; If applicable, JSqlParser will be used"); + } + + if (PersistenceProvider.ECLIPSELINK.isPresent()) { + LOG.info("EclipseLink is in classpath; If applicable, EQL parser will be used."); + } + + if (PersistenceProvider.HIBERNATE.isPresent()) { + LOG.info("Hibernate is in classpath; If applicable, HQL parser will be used."); + } + } + + enum BuiltinQueryEnhancerFactories implements QueryEnhancerFactory { + + FALLBACK { + @Override + public boolean supports(DeclaredQuery query) { + return true; + } + + @Override + public QueryEnhancer create(QueryProvider query) { + return new DefaultQueryEnhancer(query); + } + }, + + JSQLPARSER { + @Override + public boolean supports(DeclaredQuery query) { + return query.isNative(); + } + + @Override + public QueryEnhancer create(QueryProvider query) { + + if (jSqlParserPresent) { + return new JSqlParserQueryEnhancer(query); + } + + throw new IllegalStateException("JSQLParser is not available on the class path"); + } + }, + + HQL { + @Override + public boolean supports(DeclaredQuery query) { + return query.isJpql(); + } + + @Override + public QueryEnhancer create(QueryProvider query) { + return JpaQueryEnhancer.forHql(query.getQueryString()); + } + }, + EQL { + @Override + public boolean supports(DeclaredQuery query) { + return query.isJpql(); + } + + @Override + public QueryEnhancer create(QueryProvider query) { + return JpaQueryEnhancer.forEql(query.getQueryString()); + } + }, + JPQL { + @Override + public boolean supports(DeclaredQuery query) { + return query.isJpql(); + } + + @Override + public QueryEnhancer create(QueryProvider query) { + return JpaQueryEnhancer.forJpql(query.getQueryString()); + } + } + } + + /** + * Returns the default fallback {@link QueryEnhancerFactory} using regex-based detection. This factory supports only + * simple SQL queries. + * + * @return fallback {@link QueryEnhancerFactory} using regex-based detection. + */ + public static QueryEnhancerFactory fallback() { + return BuiltinQueryEnhancerFactories.FALLBACK; + } + + /** + * Returns a {@link QueryEnhancerFactory} that uses 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 5a2853cb1a..0233798594 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactory.java @@ -15,133 +15,41 @@ */ package org.springframework.data.jpa.repository.query; -import org.apache.commons.logging.Log; -import org.apache.commons.logging.LogFactory; -import org.springframework.core.SpringProperties; -import org.springframework.data.jpa.provider.PersistenceProvider; -import org.springframework.util.ClassUtils; -import org.springframework.util.ObjectUtils; -import org.springframework.util.StringUtils; - /** - * Encapsulates different strategies for the creation of a {@link QueryEnhancer} from a {@link DeclaredQuery}. + * Encapsulates different strategies for the creation of a {@link QueryEnhancer} from a {@link ParametrizedQuery}. * * @author Diego Krupitza * @author Greg Turnquist * @author Mark Paluch * @author Christoph Strobl - * @since 2.7.0 + * @since 4.0 */ -public final class QueryEnhancerFactory { - - private static final Log LOG = LogFactory.getLog(QueryEnhancerFactory.class); - private static final NativeQueryEnhancer NATIVE_QUERY_ENHANCER; - - static { - - NATIVE_QUERY_ENHANCER = NativeQueryEnhancer.select(); - - if (PersistenceProvider.ECLIPSELINK.isPresent()) { - LOG.info("EclipseLink is in classpath; If applicable, EQL parser will be used."); - } - - if (PersistenceProvider.HIBERNATE.isPresent()) { - LOG.info("Hibernate is in classpath; If applicable, HQL parser will be used."); - } - } - - private QueryEnhancerFactory() {} +public interface QueryEnhancerFactory { /** - * Creates a new {@link QueryEnhancer} for the given {@link DeclaredQuery}. + * Returns whether this QueryEnhancerFactory supports the given {@link DeclaredQuery}. * - * @param query must not be {@literal null}. - * @return an implementation of {@link QueryEnhancer} that suits the query the most + * @param query the query to be enhanced and introspected. + * @return {@code true} if this QueryEnhancer supports the given query; {@code false} otherwise. */ - public static QueryEnhancer forQuery(DeclaredQuery query) { - - if (query.isNativeQuery()) { - return getNativeQueryEnhancer(query); - } - - if (PersistenceProvider.HIBERNATE.isPresent()) { - return JpaQueryEnhancer.forHql(query); - } else if (PersistenceProvider.ECLIPSELINK.isPresent()) { - return JpaQueryEnhancer.forEql(query); - } else { - return JpaQueryEnhancer.forJpql(query); - } - } + boolean supports(DeclaredQuery query); /** - * Get the native query enhancer for the given {@link DeclaredQuery query} based on {@link #NATIVE_QUERY_ENHANCER}. + * Creates a new {@link QueryEnhancer} for the given query. * - * @param query the declared query. - * @return new instance of {@link QueryEnhancer}. + * @param query the query to be enhanced and introspected. + * @return the query enhancer to be used. */ - private static QueryEnhancer getNativeQueryEnhancer(DeclaredQuery query) { - - if (NATIVE_QUERY_ENHANCER.equals(NativeQueryEnhancer.JSQLPARSER)) { - return new JSqlParserQueryEnhancer(query); - } - - return new DefaultQueryEnhancer(query); - } + QueryEnhancer create(QueryProvider query); /** - * Possible choices for the {@link #NATIVE_PARSER_PROPERTY}. Resolve the parser through {@link #select()}. + * Creates a new {@link QueryEnhancerFactory} for the given {@link DeclaredQuery}. * - * @since 3.3.5 + * @param query must not be {@literal null}. + * @return an implementation of {@link QueryEnhancer} that suits the query the most */ - enum NativeQueryEnhancer { - - AUTO, REGEX, JSQLPARSER; - - static final String NATIVE_PARSER_PROPERTY = "spring.data.jpa.query.native.parser"; - - static final boolean JSQLPARSER_PRESENT = ClassUtils.isPresent("net.sf.jsqlparser.parser.JSqlParser", null); - - /** - * @return the current selection considering classpath availability and user selection via - * {@link #NATIVE_PARSER_PROPERTY}. - */ - static NativeQueryEnhancer select() { - - NativeQueryEnhancer selected = resolve(); - - if (selected.equals(NativeQueryEnhancer.JSQLPARSER)) { - LOG.info("User choice: Using JSqlParser"); - return NativeQueryEnhancer.JSQLPARSER; - } - - if (selected.equals(NativeQueryEnhancer.REGEX)) { - LOG.info("Using Regex QueryEnhancer"); - return NativeQueryEnhancer.REGEX; - } - - if (!JSQLPARSER_PRESENT) { - return NativeQueryEnhancer.REGEX; - } - - LOG.info("JSqlParser is in classpath; If applicable, JSqlParser will be used."); - return NativeQueryEnhancer.JSQLPARSER; - } - - /** - * Resolve {@link NativeQueryEnhancer} from {@link SpringProperties}. - * - * @return the {@link NativeQueryEnhancer} constant. - */ - private static NativeQueryEnhancer resolve() { - - String name = SpringProperties.getProperty(NATIVE_PARSER_PROPERTY); - - if (StringUtils.hasText(name)) { - return ObjectUtils.caseInsensitiveValueOf(NativeQueryEnhancer.values(), name); - } - - return AUTO; - } + static QueryEnhancerFactory forQuery(DeclaredQuery query) { + return QueryEnhancerSelector.DEFAULT_SELECTOR.select(query); } } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerSelector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerSelector.java new file mode 100644 index 0000000000..fd5f1da6ae --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryEnhancerSelector.java @@ -0,0 +1,95 @@ +/* + * Copyright 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +import org.springframework.data.jpa.provider.PersistenceProvider; + +/** + * Interface declaring a strategy to select a {@link QueryEnhancer} for a given {@link DeclaredQuery query}. + *

+ * Enhancers are selected when introspecting a query to determine their selection, joins, aliases and other information + * so that query methods can derive count queries, apply sorting and perform other rewrite transformations. + * + * @author Mark Paluch + * @since 4.0 + */ +public interface QueryEnhancerSelector { + + /** + * Default selector strategy. + */ + QueryEnhancerSelector DEFAULT_SELECTOR = new DefaultQueryEnhancerSelector(); + + /** + * Select a {@link QueryEnhancer} for a {@link DeclaredQuery query}. + * + * @param query + * @return + */ + QueryEnhancerFactory select(DeclaredQuery query); + + /** + * Default {@link QueryEnhancerSelector} implementation using class-path information to determine enhancer + * availability. Subclasses may provide a different configuration by using the protected constructor. + */ + class DefaultQueryEnhancerSelector implements QueryEnhancerSelector { + + protected static QueryEnhancerFactory DEFAULT_NATIVE; + protected static QueryEnhancerFactory DEFAULT_JPQL; + + static { + + DEFAULT_NATIVE = QueryEnhancerFactories.jSqlParserPresent ? QueryEnhancerFactories.jsqlparser() + : QueryEnhancerFactories.fallback(); + + if (PersistenceProvider.HIBERNATE.isPresent()) { + DEFAULT_JPQL = QueryEnhancerFactories.hql(); + } else if (PersistenceProvider.ECLIPSELINK.isPresent()) { + DEFAULT_JPQL = QueryEnhancerFactories.eql(); + } else { + DEFAULT_JPQL = QueryEnhancerFactories.jpql(); + } + } + + private final QueryEnhancerFactory nativeQuery; + private final QueryEnhancerFactory jpql; + + DefaultQueryEnhancerSelector() { + this(DEFAULT_NATIVE, DEFAULT_JPQL); + } + + protected DefaultQueryEnhancerSelector(QueryEnhancerFactory nativeQuery, QueryEnhancerFactory jpql) { + this.nativeQuery = nativeQuery; + this.jpql = jpql; + } + + /** + * Returns the default JPQL {@link QueryEnhancerFactory} based on class path presence of Hibernate and EclipseLink. + * + * @return the default JPQL {@link QueryEnhancerFactory}. + */ + public static QueryEnhancerFactory jpql() { + return DEFAULT_JPQL; + } + + @Override + public QueryEnhancerFactory select(DeclaredQuery query) { + return jpql.supports(query) ? jpql : nativeQuery; + } + + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformation.java index 07c1def305..b681037cbf 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformation.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryInformation.java @@ -17,7 +17,7 @@ import java.util.List; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * Value object capturing introspection details of a parsed query. @@ -44,8 +44,7 @@ class QueryInformation { * * @return */ - @Nullable - public String getAlias() { + public @Nullable String getAlias() { return alias; } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java index 727f61cc81..caeb8fd78f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryParameterSetter.java @@ -23,17 +23,16 @@ import jakarta.persistence.criteria.ParameterExpression; import java.lang.reflect.Proxy; -import java.util.Collections; import java.util.Date; -import java.util.HashMap; -import java.util.Map; import java.util.Set; import java.util.function.Function; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; + import org.springframework.util.Assert; +import org.springframework.util.ErrorHandler; /** * The interface encapsulates the setting of query parameters which might use a significant number of variations of @@ -45,158 +44,159 @@ */ interface QueryParameterSetter { - void setParameter(BindableQuery query, JpaParametersParameterAccessor accessor, ErrorHandling errorHandling); - /** Noop implementation */ - QueryParameterSetter NOOP = (query, values, errorHandling) -> {}; + QueryParameterSetter NOOP = (query, values, errorHandler) -> {}; + + /** + * Creates a new {@link QueryParameterSetter} for the given value extractor, JPA parameter and potentially the + * temporal type. + * + * @param valueExtractor + * @param parameter + * @param temporalType + * @return + */ + static QueryParameterSetter create(Function 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 c45c3d8aa3..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 @@ -18,9 +18,10 @@ import jakarta.persistence.Query; import jakarta.persistence.TemporalType; -import java.util.List; import java.util.function.Function; +import org.jspecify.annotations.Nullable; + import org.springframework.data.expression.ValueEvaluationContext; import org.springframework.data.expression.ValueEvaluationContextProvider; import org.springframework.data.expression.ValueExpression; @@ -28,14 +29,11 @@ import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; import org.springframework.data.jpa.repository.query.ParameterBinding.BindingIdentifier; import org.springframework.data.jpa.repository.query.ParameterBinding.MethodInvocationArgument; -import org.springframework.data.jpa.repository.query.ParameterMetadataProvider.ParameterMetadata; -import org.springframework.data.jpa.repository.query.QueryParameterSetter.NamedOrIndexedQueryParameterSetter; import org.springframework.data.repository.query.Parameter; import org.springframework.data.repository.query.Parameters; import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.expression.Expression; import org.springframework.expression.spel.standard.SpelExpressionParser; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -49,36 +47,44 @@ */ abstract class QueryParameterSetterFactory { - @Nullable - abstract QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery); + /** + * Creates a {@link QueryParameterSetter} for the given {@link ParameterBinding}. This factory may return + * {@literal null} if it doesn't support the given {@link ParameterBinding}. + * + * @param binding the parameter binding to create a {@link QueryParameterSetter} for. + * @return + */ + abstract @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery parametrizedQuery); /** * Creates a new {@link QueryParameterSetterFactory} for the given {@link JpaParameters}. * * @param parameters must not be {@literal null}. + * @param preferNamedParameters whether to prefer named parameters. * @return a basic {@link QueryParameterSetterFactory} that can handle named and index parameters. */ - static QueryParameterSetterFactory basic(JpaParameters parameters) { - - Assert.notNull(parameters, "JpaParameters must not be null"); - - return new BasicQueryParameterSetterFactory(parameters); + static QueryParameterSetterFactory basic(JpaParameters parameters, boolean preferNamedParameters) { + return new BasicQueryParameterSetterFactory(parameters, preferNamedParameters); } /** - * Creates a new {@link QueryParameterSetterFactory} using the given {@link JpaParameters} and - * {@link ParameterMetadata}. + * Creates a new {@link QueryParameterSetterFactory} using the given {@link JpaParameters}. * * @param parameters must not be {@literal null}. - * @param metadata must not be {@literal null}. - * @return a {@link QueryParameterSetterFactory} for criteria Queries. + * @return a {@link QueryParameterSetterFactory} for Part-Tree Queries. */ - static QueryParameterSetterFactory forCriteriaQuery(JpaParameters parameters, List> metadata) { - - Assert.notNull(parameters, "JpaParameters must not be null"); - Assert.notNull(metadata, "ParameterMetadata must not be null"); + static QueryParameterSetterFactory forPartTreeQuery(JpaParameters parameters) { + return new PartTreeQueryParameterSetterFactory(parameters); + } - return new CriteriaQueryParameterSetterFactory(parameters, metadata); + /** + * Creates a new {@link QueryParameterSetterFactory} to bind + * {@link org.springframework.data.jpa.repository.query.ParameterBinding.Synthetic} parameters. + * + * @return a {@link QueryParameterSetterFactory} for JPQL Queries. + */ + static QueryParameterSetterFactory forSynthetic() { + return new SyntheticParameterSetterFactory(); } /** @@ -87,16 +93,11 @@ static QueryParameterSetterFactory forCriteriaQuery(JpaParameters parameters, Li * * @param parser must not be {@literal null}. * @param evaluationContextProvider must not be {@literal null}. - * @param parameters must not be {@literal null}. * @return a {@link QueryParameterSetterFactory} that can handle * {@link org.springframework.expression.spel.standard.SpelExpression}s. */ static QueryParameterSetterFactory parsing(ValueExpressionParser parser, ValueEvaluationContextProvider evaluationContextProvider) { - - Assert.notNull(parser, "ValueExpressionParser must not be null"); - Assert.notNull(evaluationContextProvider, "ValueEvaluationContextProvider must not be null"); - return new ExpressionBasedQueryParameterSetterFactory(parser, evaluationContextProvider); } @@ -108,19 +109,18 @@ static QueryParameterSetterFactory parsing(ValueExpressionParser parser, * @param binding the binding of the query parameter to be set. * @param parameter the method parameter to bind. */ - private static QueryParameterSetter createSetter(Function 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(); @@ -168,7 +168,6 @@ private static class ExpressionBasedQueryParameterSetterFactory extends QueryPar /** * @param parser must not be {@literal null}. * @param evaluationContextProvider must not be {@literal null}. - * @param parameters must not be {@literal null}. */ ExpressionBasedQueryParameterSetterFactory(ValueExpressionParser parser, ValueEvaluationContextProvider evaluationContextProvider) { @@ -180,9 +179,8 @@ private static class ExpressionBasedQueryParameterSetterFactory extends QueryPar this.evaluationContextProvider = evaluationContextProvider; } - @Nullable @Override - public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery) { + public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery parametrizedQuery) { if (!(binding.getOrigin() instanceof ParameterBinding.Expression e)) { return null; @@ -198,14 +196,32 @@ public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery decla * @param accessor must not be {@literal null}. * @return the result of the evaluation. */ - @Nullable - private Object evaluateExpression(ValueExpression expression, JpaParametersParameterAccessor accessor) { + private @Nullable Object evaluateExpression(ValueExpression expression, JpaParametersParameterAccessor accessor) { ValueEvaluationContext evaluationContext = evaluationContextProvider.getEvaluationContext(accessor.getValues()); return expression.evaluate(evaluationContext); } } + /** + * Handles synthetic bindings that have been captured during parameter augmenting. + * + * @author Mark Paluch + * @since 4.0 + */ + private static class SyntheticParameterSetterFactory extends QueryParameterSetterFactory { + + @Override + public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery query) { + + if (!(binding.getOrigin() instanceof ParameterBinding.Synthetic s)) { + return null; + } + + return createSetter(values -> s.value(), binding, null); + } + } + /** * Extracts values for parameter bindings from method parameters. It handles named as well as indexed parameters. * @@ -217,30 +233,33 @@ private Object evaluateExpression(ValueExpression expression, JpaParametersParam private static class BasicQueryParameterSetterFactory extends QueryParameterSetterFactory { private final JpaParameters parameters; + private final boolean preferNamedParameters; /** * @param parameters must not be {@literal null}. + * @param preferNamedParameters whether to use named parameters. */ - BasicQueryParameterSetterFactory(JpaParameters parameters) { + BasicQueryParameterSetterFactory(JpaParameters parameters, boolean preferNamedParameters) { Assert.notNull(parameters, "JpaParameters must not be null"); this.parameters = parameters; + this.preferNamedParameters = preferNamedParameters; } @Override - public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery declaredQuery) { + public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery query) { Assert.notNull(binding, "Binding must not be null"); - JpaParameter parameter; if (!(binding.getOrigin() instanceof MethodInvocationArgument mia)) { - return QueryParameterSetter.NOOP; + return null; } BindingIdentifier identifier = mia.identifier(); + JpaParameter parameter; - if (declaredQuery.hasNamedParameter() && identifier.hasName()) { + if (preferNamedParameters && identifier.hasName()) { parameter = findParameterForBinding(parameters, identifier.getName()); } else if (identifier.hasPosition()) { parameter = findParameterForBinding(parameters, identifier.getPosition() - 1); @@ -254,8 +273,7 @@ public QueryParameterSetter create(ParameterBinding binding, DeclaredQuery decla : createSetter(values -> getValue(values, parameter), binding, parameter); } - @Nullable - private Object getValue(JpaParametersParameterAccessor accessor, Parameter parameter) { + protected @Nullable Object getValue(JpaParametersParameterAccessor accessor, Parameter parameter) { return accessor.getValue(parameter); } } @@ -263,60 +281,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) { + public @Nullable QueryParameterSetter create(ParameterBinding binding, ParametrizedQuery query) { - int parameterIndex = binding.getRequiredPosition() - 1; + if (binding instanceof ParameterBinding.PartTreeParameterBinding ptb) { - Assert.isTrue( // - parameterIndex < parameterMetadata.size(), // - () -> String.format( // - "At least %s parameter(s) provided but only %s parameter(s) present in query", // - binding.getRequiredPosition(), // - parameterMetadata.size() // - ) // - ); + if (ptb.isIsNullParameter()) { + return QueryParameterSetter.NOOP; + } - ParameterMetadata metadata = parameterMetadata.get(parameterIndex); - - if (metadata.isIsNullParameter()) { - return QueryParameterSetter.NOOP; + return super.create(binding, query); } - JpaParameter parameter = parameters.getBindableParameter(parameterIndex); - TemporalType temporalType = parameter.isTemporalParameter() ? parameter.getRequiredTemporalType() : null; - - return new NamedOrIndexedQueryParameterSetter(values -> getAndPrepare(parameter, metadata, values), - metadata.getExpression(), temporalType); - } + 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; } } @@ -344,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; } @@ -360,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 7517a2a7e1..98de7da6eb 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/OpenJpaParameterMetadataProviderIntegrationTests.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryProvider.java @@ -1,5 +1,5 @@ /* - * Copyright 2015-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,13 +15,23 @@ */ package org.springframework.data.jpa.repository.query; -import org.springframework.test.context.ContextConfiguration; - /** - * OpenJpa-specific tests for {@link ParameterMetadataProvider}. + * Interface indicating an object that contains and exposes an {@code query string}. This can be either a JPQL query + * string or a SQL query string. * - * @author Oliver Gierke - * @soundtrack Elephants Crossing - We are (Irrelephant) + * @author Christoph Strobl + * @author Mark Paluch + * @since 4.0 + * @see DeclaredQuery#jpqlQuery(String) + * @see DeclaredQuery#nativeQuery(String) */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaParameterMetadataProviderIntegrationTests extends ParameterMetadataProviderIntegrationTests {} +public interface QueryProvider { + + /** + * Return the query string. + * + * @return the query string. + */ + String getQueryString(); + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRenderer.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRenderer.java index 3039ef735a..2b7109914d 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRenderer.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryRenderer.java @@ -20,9 +20,11 @@ import java.util.Collections; import java.util.Iterator; import java.util.List; -import java.util.function.Function; import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; import org.springframework.util.CompositeIterator; /** @@ -42,21 +44,8 @@ */ abstract class QueryRenderer implements QueryTokenStream { - /** - * Creates a QueryRenderer from a {@link QueryToken}. - * - * @param token - * @return - */ - static QueryRenderer from(QueryToken token) { - return QueryRenderer.from(Collections.singletonList(token)); - } - /** * Creates a QueryRenderer from a collection of {@link QueryToken}. - * - * @param tokens - * @return */ static QueryRenderer from(Collection tokens) { List tokensToUse = new ArrayList<>(Math.max(tokens.size(), 32)); @@ -66,9 +55,6 @@ static QueryRenderer from(Collection tokens) { /** * Creates a QueryRenderer from a {@link QueryTokenStream}. - * - * @param tokens - * @return */ static QueryRenderer from(QueryTokenStream tokens) { @@ -85,8 +71,6 @@ static QueryRenderer from(QueryTokenStream tokens) { /** * Creates a new empty {@link QueryRenderer}. - * - * @return */ public static QueryRenderer empty() { return EmptyQueryRenderer.INSTANCE; @@ -94,8 +78,6 @@ public static QueryRenderer empty() { /** * Creates a new {@link QueryRendererBuilder}. - * - * @return */ static QueryRendererBuilder builder() { return new QueryRendererBuilder(); @@ -144,14 +126,11 @@ static String render(Iterable tokenStream) { results.append(token.value()); } - return results.toString(); + return results != null ? results.toString() : ""; } /** * Append a {@link QueryRenderer} to create a composed renderer. - * - * @param tokens - * @return */ QueryRenderer append(QueryTokenStream tokens) { @@ -180,7 +159,11 @@ public String toString() { return render(); } - public static QueryRenderer expression(QueryTokenStream tokenStream) { + public static QueryRenderer ofExpression(QueryTokenStream tokenStream) { + + if (tokenStream instanceof ExpressionRenderer er) { + return er; + } if (tokenStream instanceof QueryRendererBuilder builder) { tokenStream = builder.current; @@ -190,6 +173,10 @@ public static QueryRenderer expression(QueryTokenStream tokenStream) { return EmptyQueryRenderer.INSTANCE; } + if (!(tokenStream instanceof QueryRenderer)) { + tokenStream = QueryRenderer.from(tokenStream); + } + if (tokenStream.isExpression()) { return (QueryRenderer) tokenStream; } @@ -199,6 +186,12 @@ public static QueryRenderer expression(QueryTokenStream 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; } @@ -207,8 +200,8 @@ public static QueryRenderer inline(QueryTokenStream tokenStream) { return EmptyQueryRenderer.INSTANCE; } - if (!tokenStream.isExpression()) { - return (QueryRenderer) tokenStream; + if (!(tokenStream instanceof QueryRenderer)) { + tokenStream = QueryRenderer.from(tokenStream); } return new InlineRenderer((QueryRenderer) tokenStream); @@ -258,9 +251,6 @@ String render() { /** * Append a {@link QueryRenderer} to create a composed renderer. - * - * @param tokens - * @return */ QueryRenderer append(QueryTokenStream tokens) { @@ -290,7 +280,7 @@ QueryRenderer append(QueryTokenStream tokens) { } @Override - public QueryToken getLast() { + public @Nullable QueryToken getLast() { for (int i = nested.size() - 1; i > -1; i--) { @@ -341,6 +331,7 @@ public int size() { public boolean isExpression() { return !nested.isEmpty() && nested.get(nested.size() - 1).isExpression(); } + } /** @@ -359,17 +350,6 @@ String render() { return render(tokens); } - @Override - QueryRenderer append(QueryTokenStream tokens) { - - if (tokens instanceof TokenRenderer tr) { - this.tokens.addAll(tr.tokens); - return this; - } - - return super.append(tokens); - } - @Override public Stream stream() { return tokens.stream(); @@ -386,12 +366,12 @@ public List toList() { } @Override - public QueryToken getFirst() { + public @Nullable QueryToken getFirst() { return tokens.isEmpty() ? null : tokens.get(0); } @Override - public QueryToken getLast() { + public @Nullable QueryToken getLast() { return tokens.isEmpty() ? null : tokens.get(tokens.size() - 1); } @@ -407,30 +387,7 @@ public boolean isEmpty() { @Override public boolean isExpression() { - return !tokens.isEmpty() && getLast().isExpression(); - } - - /** - * Render a list of {@link QueryTokens.SimpleQueryToken}s into a string. - * - * @param tokens - * @return rendered string containing either a query or some subset of that query - */ - static String render(Object tokens) { - - if (tokens instanceof Collection tpr) { - return render(tpr); - } - - if (tokens instanceof QueryRendererBuilder qrb) { - return qrb.build().render(); - } - - if (tokens instanceof QueryRenderer qr) { - return qr.render(); - } - - throw new IllegalArgumentException("Unknown token type %s".formatted(tokens)); + return !tokens.isEmpty() && getRequiredLast().isExpression(); } } @@ -454,12 +411,12 @@ public Iterator iterator() { } @Override - public QueryToken getFirst() { + public @Nullable QueryToken getFirst() { return tokens.getFirst(); } @Override - public QueryToken getLast() { + public @Nullable QueryToken getLast() { return tokens.getLast(); } @@ -475,7 +432,7 @@ public boolean isEmpty() { @Override public boolean isExpression() { - return !tokens.isEmpty() && tokens.getLast().isExpression(); + return !tokens.isEmpty() && tokens.getRequiredLast().isExpression(); } } @@ -487,82 +444,7 @@ static class QueryRendererBuilder implements QueryTokenStream { protected QueryRenderer current = QueryRenderer.empty(); /** - * Compose a {@link QueryRendererBuilder} from a collection of inline elements that can be mapped to - * {@link QueryRendererBuilder}. - * - * @param elements - * @param visitor - * @param separator - * @return - * @param - */ - public static QueryRendererBuilder concat(Collection elements, Function visitor, - QueryToken separator) { - return concat(elements, visitor, QueryRendererBuilder::toInline, separator); - } - - /** - * Compose a {@link QueryRendererBuilder} from a collection of expression elements that can be mapped to - * {@link QueryRendererBuilder}. - * - * @param elements - * @param visitor - * @param separator - * @return - * @param - */ - public static QueryRendererBuilder concatExpressions(Collection elements, - Function visitor, QueryToken separator) { - return concat(elements, visitor, QueryRendererBuilder::toExpression, separator); - } - - /** - * Compose a {@link QueryRendererBuilder} from a collection of elements that can be mapped to - * {@link QueryRendererBuilder}. - * - * @param elements - * @param visitor - * @param postProcess post-processing function to convert {@link QueryRendererBuilder} into {@link QueryRenderer}. - * @param separator - * @return - * @param - */ - public static QueryRendererBuilder concat(Collection elements, Function visitor, - Function postProcess, QueryToken separator) { - - QueryRendererBuilder builder = new QueryRendererBuilder(); - for (T element : elements) { - if (!builder.isEmpty()) { - builder.append(separator); - } - builder.append(postProcess.apply(visitor.apply(element))); - } - - return builder; - } - - /** - * Create and initialize a QueryRendererBuilder from a {@link QueryTokens.SimpleQueryToken}. - * - * @param token - * @return - */ - public static QueryRendererBuilder from(QueryToken token) { - return new QueryRendererBuilder().append(token); - } - - /** - * Append a {@link QueryTokens.SimpleQueryToken}. - * - * @param token - * @return {@code this} builder. - */ - QueryRendererBuilder append(QueryToken token) { - return append(QueryRenderer.from(token)); - } - - /** - * Append a collection of {@link QueryTokens.SimpleQueryToken}. + * Append a collection of {@link QueryToken}s. * * @param tokens * @return {@code this} builder. @@ -571,16 +453,6 @@ QueryRendererBuilder append(List tokens) { return append(QueryRenderer.from(tokens)); } - /** - * Append a QueryRendererBuilder as expression. - * - * @param builder - * @return {@code this} builder. - */ - QueryRendererBuilder appendExpression(QueryRendererBuilder builder) { - return appendExpression(builder.current); - } - /** * Append a QueryRenderer. * @@ -615,6 +487,16 @@ QueryRendererBuilder appendInline(QueryTokenStream 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. * @@ -627,7 +509,7 @@ QueryRendererBuilder appendExpression(QueryTokenStream tokens) { return this; } - current = current.append(QueryRenderer.expression(tokens)); + current = current.append(QueryRenderer.ofExpression(tokens)); return this; } @@ -643,12 +525,12 @@ public Stream stream() { } @Override - public QueryToken getFirst() { + public @Nullable QueryToken getFirst() { return current.getFirst(); } @Override - public QueryToken getLast() { + public @Nullable QueryToken getLast() { return current.getLast(); } @@ -657,11 +539,6 @@ public boolean isExpression() { return current.isExpression(); } - /** - * Return whet the builder is empty. - * - * @return - */ @Override public boolean isEmpty() { return current.isEmpty(); @@ -686,19 +563,9 @@ public QueryRenderer build() { return current; } - QueryRenderer toExpression() { - - if (current instanceof ExpressionRenderer) { - return current; - } - - return QueryRenderer.expression(current); - } - public QueryRenderer toInline() { return new InlineRenderer(current); } - } private static class InlineRenderer extends QueryRenderer { @@ -730,12 +597,12 @@ public Iterator iterator() { } @Override - public QueryToken getFirst() { + public @Nullable QueryToken getFirst() { return delegate.getFirst(); } @Override - public QueryToken getLast() { + public @Nullable QueryToken getLast() { return delegate.getLast(); } @@ -784,12 +651,12 @@ public Iterator iterator() { } @Override - public QueryToken getFirst() { + public @Nullable QueryToken getFirst() { return delegate.getFirst(); } @Override - public QueryToken getLast() { + public @Nullable QueryToken getLast() { return delegate.getLast(); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryToken.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryToken.java index ac67b8bf0d..7cf9ccbcb3 100644 --- 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 @@ -22,7 +22,7 @@ * @author Christoph Strobl * @since 3.4 */ -interface QueryToken { +interface QueryToken extends QueryTokenStream { /** * @return the token value (i.e. its content). diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokenStream.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokenStream.java index c91fddb0e4..c1b01daf14 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokenStream.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokenStream.java @@ -15,13 +15,19 @@ */ 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.springframework.data.jpa.repository.query.QueryRenderer.QueryRendererBuilder; +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.lang.Nullable; import org.springframework.util.CollectionUtils; /** @@ -35,8 +41,6 @@ interface QueryTokenStream extends Streamable { /** * Creates an empty stream. - * - * @return */ static QueryTokenStream empty() { return EmptyQueryTokenStream.INSTANCE; @@ -55,10 +59,6 @@ static QueryTokenStream concat(Collection elements, Function QueryTokenStream justAs(Collection elements, Function converter) { - return concat(elements, it-> QueryRendererBuilder.from(converter.apply(it)), QueryRenderer::inline, QueryTokens.TOKEN_SPACE); - } - /** * Compose a {@link QueryTokenStream} from a collection of expression elements. * @@ -69,7 +69,68 @@ static QueryTokenStream justAs(Collection elements, Function QueryTokenStream concatExpressions(Collection elements, Function visitor, QueryToken separator) { - return concat(elements, visitor, QueryRenderer::expression, 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(); } /** @@ -117,24 +178,90 @@ static QueryTokenStream concat(Collection elements, Function it = iterator(); return it.hasNext() ? it.next() : null; } + /** + * @return the required first query token or throw {@link java.util.NoSuchElementException} if empty. + * @since 4.0 + */ + default QueryToken getRequiredFirst() { + + QueryToken first = getFirst(); + + if (first == null) { + throw new NoSuchElementException("No token in the stream"); + } + + return first; + } + /** * @return the last query token or {@code null} if empty. */ - @Nullable - default QueryToken getLast() { + default @Nullable QueryToken getLast() { return CollectionUtils.lastElement(toList()); } + /** + * @return the required last query token or throw {@link java.util.NoSuchElementException} if empty. + * @since 4.0 + */ + default QueryToken getRequiredLast() { + + QueryToken last = getLast(); + + if (last == null) { + throw new NoSuchElementException("No token in the stream"); + } + + return last; + } + /** * @return {@code true} if this stream represents a query expression. */ diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java index ea95343d42..8d6a76e74f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryTokens.java @@ -15,6 +15,8 @@ */ 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; @@ -31,7 +33,7 @@ class QueryTokens { /** * Commonly use tokens. */ - static final QueryToken TOKEN_NONE = token(""); + static final QueryToken EMPTY_TOKEN = token(""); static final QueryToken TOKEN_COMMA = token(", "); static final QueryToken TOKEN_SPACE = token(" "); static final QueryToken TOKEN_DOT = token("."); @@ -58,15 +60,9 @@ class QueryTokens { static final QueryToken TOKEN_WITH = expression("WITH"); static final QueryToken TOKEN_NOT = expression("NOT"); static final QueryToken TOKEN_MATERIALIZED = expression("materialized"); - static final QueryToken TOKEN_NULLS = expression("NULLS"); - static final QueryToken TOKEN_FIRST = expression("FIRST"); - static final QueryToken TOKEN_LAST = expression("LAST"); /** * Creates a {@link QueryToken token} from an ANTLR {@link TerminalNode}. - * - * @param node - * @return */ static QueryToken token(TerminalNode node) { return token(node.getText()); @@ -74,9 +70,6 @@ static QueryToken token(TerminalNode node) { /** * Creates a {@link QueryToken token} from an ANTLR {@link Token}. - * - * @param token - * @return */ static QueryToken token(Token token) { return token(token.getText()); @@ -84,29 +77,13 @@ static QueryToken token(Token token) { /** * Creates a {@link QueryToken token} from a string {@code token}. - * - * @param token - * @return */ static QueryToken token(String token) { return new SimpleQueryToken(token); } - /** - * Creates a ventilated token that is embedded in spaces. - * - * @param token - * @return - */ - static QueryToken ventilated(Token token) { - return new SimpleQueryToken(" " + token.getText() + " "); - } - /** * Creates a {@link QueryToken expression} from an ANTLR {@link TerminalNode}. - * - * @param node - * @return */ static QueryToken expression(TerminalNode node) { return expression(node.getText()); @@ -114,9 +91,6 @@ static QueryToken expression(TerminalNode node) { /** * Creates a {@link QueryToken expression} from an ANTLR {@link Token}. - * - * @param token - * @return */ static QueryToken expression(Token token) { return expression(token.getText()); @@ -124,9 +98,6 @@ static QueryToken expression(Token token) { /** * Creates a {@link QueryToken token} from a string {@code expression}. - * - * @param expression - * @return */ static QueryToken expression(String expression) { return new ExpressionToken(expression); @@ -140,7 +111,7 @@ static QueryToken expression(String expression) { * @author Christoph Strobl * @since 3.1 */ - static class SimpleQueryToken implements QueryToken { + static class SimpleQueryToken implements QueryToken, QueryTokenStream { /** * The text value of the token. @@ -168,6 +139,26 @@ public final boolean equals(Object object) { 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(); @@ -185,8 +176,10 @@ static class ExpressionToken extends SimpleQueryToken { super(token); } + @Override public boolean isExpression() { return true; } } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/QueryUtils.java index 371dc0b6cc..40c61a765f 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 @@ -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,30 +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.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +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; @@ -86,12 +88,13 @@ * @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 @@ -130,8 +133,6 @@ public abstract class QueryUtils { 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; @@ -166,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 @@ -193,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); } /** @@ -389,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); @@ -443,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)); @@ -530,6 +517,21 @@ private static Integer findClose(final Integer open, final List closes, * @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"); @@ -541,9 +543,46 @@ 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); - Query query = entityManager.createQuery("%s where %s IN (?1)".formatted(queryString, alias)); - query.setParameter(1, entities instanceof Collection ? entities : Streamable.of(entities).toList()); + StringBuilder builder = new StringBuilder(queryString); + builder.append(" where"); + + int i = 0; + + while (iterator.hasNext()) { + + iterator.next(); + + builder.append(String.format(" %s = ?%d", alias, ++i)); + + if (iterator.hasNext()) { + builder.append(" or"); + } + } + + Query query = entityManager.createQuery(builder.toString()); + + iterator = entities.iterator(); + i = 0; + + while (iterator.hasNext()) { + query.setParameter(++i, iterator.next()); + } return query; } @@ -553,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); } @@ -567,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); } @@ -583,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"); @@ -724,266 +760,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.getNullHandling() != Sort.NullHandling.NATIVE) { - throw new UnsupportedOperationException("Applying Null Precedence using Criteria Queries is not yet supported."); + if (order instanceof JpaOrder jpaOrder && jpaOrder.isUnsafe()) { + expression = new HqlOrderExpressionVisitor(cb, from, QueryUtils::toExpressionRecursively) + .createCriteriaExpression(order); + } else { + PropertyPath property = PropertyPath.from(order.getProperty(), from.getJavaType()); + expression = toExpressionRecursively(from, property); } + Nulls nulls = toNulls(order.getNullHandling()); + if (order.isIgnoreCase() && String.class.equals(expression.getJavaType())) { Expression 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) { - public 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") + 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 (!(propertyPathModel instanceof Attribute attribute)) { - return false; - } - - // not a persistent attribute type association (@OneToOne, @ManyToOne) - if (!ASSOCIATION_TYPES.containsKey(attribute.getPersistentAttributeType())) { - return false; - } + // if it's a leaf, return the join + if (isLeafProperty) { + return (Expression) join; + } - 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", "")); + 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; + } - boolean isLeafProperty = !property.hasNext(); - if (isLeafProperty && !isForSelection && !isCollection && !isInverseOptionalOneToOne && !hasRequiredOuterJoin) { - return false; + return super.requiresOuterJoin(resolver, property, isForSelection, hasRequiredOuterJoin, isLeafProperty, + isRelationshipId); } - return hasRequiredOuterJoin || getAnnotationProperty(attribute, "optional", true); - } - - @Nullable - private static T getAnnotationProperty(Attribute attribute, String propertyName, T defaultValue) { + /** + * 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) { - Class associationAnnotation = ASSOCIATION_TYPES.get(attribute.getPersistentAttributeType()); + for (Fetch fetch : from.getFetches()) { - if (associationAnnotation == null) { - return defaultValue; - } + if (fetch instanceof Join join && join.getAttribute().getName().equals(attribute)) { + return join; + } + } - Member member = attribute.getJavaMember(); + for (Join join : from.getJoins()) { - if (!(member instanceof AnnotatedElement annotatedMember)) { - return defaultValue; + if (join.getAttribute().getName().equals(attribute)) { + return join; + } + } + return from.join(attribute, joinType); } - Annotation annotation = AnnotationUtils.getAnnotation(annotatedMember, associationAnnotation); - return annotation == null ? defaultValue : (T) AnnotationUtils.getValue(annotation, propertyName); - } + /** + * 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) { - /** - * 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) { + for (Fetch fetch : from.getFetches()) { - for (Fetch fetch : from.getFetches()) { - - if (fetch instanceof Join join && join.getAttribute().getName().equals(attribute)) { - return join; + 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; - } - - /** - * 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) { + Bindable propertyPathModel = resolve(propertyPath); - 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/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 c9a80e4a38..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 @@ -18,10 +18,10 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.Query; -import org.springframework.data.jpa.repository.QueryRewriter; +import org.jspecify.annotations.Nullable; + +import org.springframework.data.repository.query.QueryCreationException; import org.springframework.data.repository.query.RepositoryQuery; -import org.springframework.data.repository.query.ValueExpressionDelegate; -import org.springframework.lang.Nullable; /** * {@link RepositoryQuery} implementation that inspects a {@link org.springframework.data.repository.query.QueryMethod} @@ -33,42 +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 valueExpressionDelegate must not be {@literal null} - */ - public SimpleJpaQuery(JpaQueryMethod method, EntityManager em, @Nullable String countQueryString, - QueryRewriter queryRewriter, ValueExpressionDelegate valueExpressionDelegate) { - this(method, em, method.getRequiredAnnotatedQuery(), countQueryString, queryRewriter, valueExpressionDelegate); - } +class SimpleJpaQuery extends AbstractStringBasedJpaQuery { /** * Creates a new {@link SimpleJpaQuery} that encapsulates a simple query string. * - * @param method must not be {@literal null} - * @param em must not be {@literal null} - * @param queryString must not be {@literal null} or empty - * @param countQueryString - * @param queryRewriter - * @param valueExpressionDelegate must not be {@literal null} + * @param method must not be {@literal null}. + * @param em must not be {@literal null}. + * @param query must not be {@literal null} or empty. + * @param countQuery can be {@literal null} if not defined. + * @param queryConfiguration must not be {@literal null}. */ - public SimpleJpaQuery(JpaQueryMethod method, EntityManager em, String queryString, @Nullable String countQueryString, QueryRewriter queryRewriter, - ValueExpressionDelegate valueExpressionDelegate) { + public SimpleJpaQuery(JpaQueryMethod method, EntityManager em, DeclaredQuery query, + @Nullable DeclaredQuery countQuery, JpaQueryConfiguration queryConfiguration) { - super(method, em, queryString, countQueryString, queryRewriter, valueExpressionDelegate); + super(method, em, query, countQuery, queryConfiguration); - validateQuery(getQuery().getQueryString(), "Validation failed for query for method %s", method); + 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); } } @@ -78,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 2616c3d796..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 @@ -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 e7ef76a3eb..0429ac5f6f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributes.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureAttributes.java @@ -22,6 +22,7 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import org.jspecify.annotations.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; @@ -93,7 +94,7 @@ private ProcedureParameter getParameterWithCompletedName(ProcedureParameter para parameter.getType()); } - private String completeOutputParameterName(int i, String paramName) { + private String completeOutputParameterName(int i, @Nullable String paramName) { return StringUtils.hasText(paramName) // ? paramName // diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java index 8ff29f4ba2..caece33d0f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/StoredProcedureJpaQuery.java @@ -26,9 +26,10 @@ import java.util.Map; import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; + +import org.jspecify.annotations.Nullable; import org.springframework.data.repository.query.Parameter; import org.springframework.data.repository.query.QueryMethod; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.StringUtils; @@ -50,7 +51,6 @@ class StoredProcedureJpaQuery extends AbstractJpaQuery { private final StoredProcedureAttributes procedureAttributes; private final boolean useNamedParameters; - private final QueryParameterSetter.QueryMetadataCache metadataCache = new QueryParameterSetter.QueryMetadataCache(); /** * Creates a new {@link StoredProcedureJpaQuery}. @@ -81,6 +81,11 @@ private static boolean useNamedParameters(QueryMethod method) { return false; } + @Override + public boolean hasDeclaredCountQuery() { + return false; + } + @Override protected StoredProcedureQuery createQuery(JpaParametersParameterAccessor accessor) { return applyHints(doCreateQuery(accessor), getQueryMethod()); @@ -90,9 +95,7 @@ protected StoredProcedureQuery createQuery(JpaParametersParameterAccessor access protected StoredProcedureQuery doCreateQuery(JpaParametersParameterAccessor accessor) { StoredProcedureQuery storedProcedure = createStoredProcedure(); - QueryParameterSetter.QueryMetadata metadata = metadataCache.getMetadata("singleton", storedProcedure); - - return parameterBinder.get().bind(storedProcedure, metadata, accessor); + return parameterBinder.get().bind(storedProcedure, accessor); } @Override diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/TemplatedQuery.java similarity index 59% rename from spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java rename to spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/TemplatedQuery.java index 3007f494ca..487a7b11f8 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/TemplatedQuery.java @@ -18,17 +18,18 @@ import java.util.Objects; import java.util.regex.Pattern; +import org.springframework.core.env.Environment; +import org.springframework.core.env.StandardEnvironment; import org.springframework.data.expression.ValueEvaluationContext; import org.springframework.data.expression.ValueExpression; import org.springframework.data.expression.ValueExpressionParser; -import org.springframework.data.repository.core.EntityMetadata; -import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.expression.spel.support.SimpleEvaluationContext; import org.springframework.util.Assert; /** - * Extension of {@link StringQuery} that evaluates the given query string as a SpEL template-expression. + * Factory methods to obtain {@link EntityQuery} from a declared query using SpEL template-expressions. *

- * 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. *
      @@ -40,7 +41,7 @@ * @author Diego Krupitza * @author Greg Turnquist */ -class ExpressionBasedStringQuery extends StringQuery { +class TemplatedQuery { private static final String EXPRESSION_PARAMETER = "$1#{"; private static final String QUOTED_EXPRESSION_PARAMETER = "$1__HASH__{"; @@ -52,31 +53,42 @@ class ExpressionBasedStringQuery extends StringQuery { private static final String ENTITY_NAME_VARIABLE = "#" + ENTITY_NAME; private static final String ENTITY_NAME_VARIABLE_EXPRESSION = "#{" + ENTITY_NAME_VARIABLE; + private static final Environment DEFAULT_ENVIRONMENT; + + static { + DEFAULT_ENVIRONMENT = new StandardEnvironment(); + } + /** - * Creates a new {@link ExpressionBasedStringQuery} for the given query and {@link EntityMetadata}. + * Create a {@link DefaultEntityQuery} given {@link String query}, {@link JpaQueryMethod} and + * {@link JpaQueryConfiguration}. * - * @param query must not be {@literal null} or empty. - * @param metadata must not be {@literal null}. - * @param parser must not be {@literal null}. - * @param nativeQuery is a given query is native or not + * @param queryString must not be {@literal null}. + * @param queryMethod must not be {@literal null}. + * @param queryContext must not be {@literal null}. + * @return the created {@link DefaultEntityQuery}. */ - public ExpressionBasedStringQuery(String query, JpaEntityMetadata metadata, ValueExpressionParser parser, - boolean nativeQuery) { - super(renderQueryIfExpressionOrReturnQuery(query, metadata, parser), nativeQuery && !containsExpression(query)); + public static EntityQuery create(String queryString, JpaQueryMethod queryMethod, JpaQueryConfiguration queryContext) { + return create(queryMethod.getDeclaredQuery(queryString), queryMethod.getEntityInformation(), queryContext); } /** - * Creates an {@link ExpressionBasedStringQuery} from a given {@link DeclaredQuery}. + * Create a {@link DefaultEntityQuery} given {@link DeclaredQuery query}, {@link JpaEntityMetadata} and + * {@link JpaQueryConfiguration}. * - * @param query the original query. Must not be {@literal null}. - * @param metadata the {@link JpaEntityMetadata} for the given entity. Must not be {@literal null}. - * @param parser Parser for resolving SpEL expressions. Must not be {@literal null}. - * @param nativeQuery is a given query native or not - * @return A query supporting SpEL expressions. + * @param declaredQuery must not be {@literal null}. + * @param entityMetadata must not be {@literal null}. + * @param queryContext must not be {@literal null}. + * @return the created {@link DefaultEntityQuery}. */ - static ExpressionBasedStringQuery from(DeclaredQuery query, JpaEntityMetadata metadata, - ValueExpressionParser parser, boolean nativeQuery) { - return new ExpressionBasedStringQuery(query.getQueryString(), metadata, parser, nativeQuery); + public static EntityQuery create(DeclaredQuery declaredQuery, JpaEntityMetadata entityMetadata, + JpaQueryConfiguration queryContext) { + + ValueExpressionParser expressionParser = queryContext.getValueExpressionDelegate().getValueExpressionParser(); + String resolvedExpressionQuery = renderQueryIfExpressionOrReturnQuery(declaredQuery.getQueryString(), + entityMetadata, expressionParser); + + return EntityQuery.create(declaredQuery.rewrite(resolvedExpressionQuery), queryContext.getSelector()); } /** @@ -84,7 +96,7 @@ static ExpressionBasedStringQuery from(DeclaredQuery query, JpaEntityMetadata * @param metadata the {@link JpaEntityMetadata} for the given entity. Must not be {@literal null}. * @param parser Must not be {@literal null}. */ - private static String renderQueryIfExpressionOrReturnQuery(String query, JpaEntityMetadata metadata, + static String renderQueryIfExpressionOrReturnQuery(String query, JpaEntityMetadata metadata, ValueExpressionParser parser) { Assert.notNull(query, "query must not be null"); @@ -95,14 +107,14 @@ private static String renderQueryIfExpressionOrReturnQuery(String query, JpaEnti return query; } - StandardEvaluationContext evalContext = new StandardEvaluationContext(); + SimpleEvaluationContext evalContext = SimpleEvaluationContext.forReadOnlyDataBinding().build(); evalContext.setVariable(ENTITY_NAME, metadata.getEntityName()); query = potentiallyQuoteExpressionsParameter(query); ValueExpression expr = parser.parse(query); - String result = Objects.toString(expr.evaluate(ValueEvaluationContext.of(null, evalContext))); + String result = Objects.toString(expr.evaluate(ValueEvaluationContext.of(DEFAULT_ENVIRONMENT, evalContext))); if (result == null) { return query; @@ -122,4 +134,5 @@ private static String potentiallyQuoteExpressionsParameter(String query) { private static boolean containsExpression(String query) { return query.contains(ENTITY_NAME_VARIABLE_EXPRESSION); } + } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/package-info.java index efbf2d7af3..9f42b926da 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/package-info.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/query/package-info.java @@ -1,5 +1,5 @@ /** * Query implementation to execute queries against JPA. */ -@org.springframework.lang.NonNullApi +@org.jspecify.annotations.NullMarked package org.springframework.data.jpa.repository.query; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/CrudMethodMetadata.java index 4b0b7bacaf..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 @@ -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 135d3c6e44..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 @@ -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 220ac48587..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 @@ -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 228251d4f2..12c05b6e76 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/DefaultQueryHints.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/DefaultQueryHints.java @@ -17,13 +17,12 @@ import jakarta.persistence.EntityManager; -import java.util.Optional; import java.util.function.BiConsumer; +import org.jspecify.annotations.Nullable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.query.Jpa21Utils; import org.springframework.data.jpa.repository.query.JpaEntityGraph; -import org.springframework.data.util.Optionals; import org.springframework.util.Assert; /** @@ -38,7 +37,7 @@ class DefaultQueryHints implements QueryHints { private final JpaEntityInformation information; private final CrudMethodMetadata metadata; - private final Optional entityManager; + 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())) - .orElseGet(MutableQueryHints::new); + if(entityManager != null && metadata.getEntityGraph() != null) { + return Jpa21Utils.getFetchGraphHint(entityManager, getEntityGraph(metadata.getEntityGraph()), information.getJavaType()); + } + return new MutableQueryHints(); } private JpaEntityGraph getEntityGraph(EntityGraph entityGraph) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityGraphFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityGraphFactory.java index 5308fa64b8..266bd3e003 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityGraphFactory.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/EntityGraphFactory.java @@ -61,10 +61,15 @@ public static 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 4d84dfc0d4..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 @@ -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,7 +79,8 @@ public int getOrder() { @Override public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException { - for (EntityManagerFactoryBeanDefinition definition : getEntityManagerFactoryBeanDefinitions(beanFactory)) { + 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 f5d42e2257..171e59012e 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryByPredicate.java @@ -41,7 +41,6 @@ import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.support.PageableExecutionUtils; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import com.querydsl.core.types.EntityPath; @@ -52,6 +51,7 @@ import com.querydsl.core.types.dsl.PathBuilder; import com.querydsl.jpa.JPQLSerializer; import com.querydsl.jpa.impl.AbstractJPAQuery; +import org.jspecify.annotations.Nullable; /** * Immutable implementation of {@link FetchableFluentQuery} based on a Querydsl {@link Predicate}. All methods that @@ -147,7 +147,7 @@ public FetchableFluentQuery project(Collection properties) { } @Override - public R oneValue() { + public @Nullable R oneValue() { List results = createSortedAndProjectedQuery(this.sort) // .limit(2) // Never need more than 2 values @@ -161,7 +161,7 @@ public R oneValue() { } @Override - public R firstValue() { + public @Nullable R firstValue() { List results = createSortedAndProjectedQuery(this.sort) // .limit(1) // Never need more than 1 value diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java index 5d87904ec3..0b21210ff9 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FetchableFluentQueryBySpecification.java @@ -26,6 +26,8 @@ import java.util.function.Function; import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; + import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -42,7 +44,6 @@ import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.query.FluentQuery; import org.springframework.data.support.PageableExecutionUtils; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -132,7 +133,7 @@ public SpecificationFluentQuery project(Collection properties) { } @Override - public R oneValue() { + public @Nullable R oneValue() { List results = createSortedAndProjectedQuery(this.sort) // .setMaxResults(2) // Never need more than 2 values @@ -146,7 +147,7 @@ public R oneValue() { } @Override - public R firstValue() { + public @Nullable R firstValue() { List results = createSortedAndProjectedQuery(this.sort) // .setMaxResults(1) // Never need more than 1 value @@ -174,18 +175,18 @@ public Window scroll(ScrollPosition scrollPosition) { @Override public Slice slice(Pageable pageable) { - return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSortOr(this.sort))) : readSlice(pageable); + return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSort())) : readSlice(pageable); } @Override public Page page(Pageable pageable) { - return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSortOr(this.sort))) : readPage(pageable, spec); + return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSort())) : readPage(pageable, spec); } @Override @SuppressWarnings({ "rawtypes", "unchecked" }) public Page page(Pageable pageable, Specification countSpec) { - return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSortOr(this.sort))) + return pageable.isUnpaged() ? new PageImpl<>(all(pageable.getSort())) : readPage(pageable, (Specification) countSpec); } @@ -242,6 +243,26 @@ private Slice readSlice(Pageable pageable) { 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) { Sort sort = pageable.getSortOr(this.sort); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java index 10b484d98a..f530e6eade 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/FluentQuerySupport.java @@ -22,6 +22,8 @@ import java.util.function.Function; import org.springframework.core.convert.support.DefaultConversionService; + +import org.jspecify.annotations.Nullable; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.ScrollPosition; @@ -29,7 +31,6 @@ import org.springframework.data.jpa.repository.query.AbstractJpaQuery; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.repository.query.ReturnedType; -import org.springframework.lang.Nullable; /** * Supporting class containing some state and convenience methods for building and executing fluent queries. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java index 9c367343c5..6b2e6361e9 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JakartaTuple.java @@ -20,8 +20,6 @@ import java.util.Arrays; import java.util.List; -import org.springframework.lang.Nullable; - import com.querydsl.core.types.Expression; import com.querydsl.core.types.ExpressionBase; import com.querydsl.core.types.ExpressionUtils; @@ -31,6 +29,7 @@ import com.querydsl.core.types.Projections; import com.querydsl.core.types.Visitor; import com.querydsl.jpa.JPQLSerializer; +import org.jspecify.annotations.Nullable; /** * Expression based on a {@link Tuple}. It's a simplified variant of {@link com.querydsl.core.types.QTuple} without @@ -72,8 +71,7 @@ protected JakartaTuple(List> args) { } @Override - @Nullable - public R accept(Visitor v, @Nullable C context) { + public @Nullable R accept(Visitor v, @Nullable C context) { if (v instanceof JPQLSerializer) { return Projections.tuple(args).accept(v, context); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java index 98828424ab..1e378c3308 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformation.java @@ -21,8 +21,9 @@ import java.util.Map; import org.springframework.data.jpa.repository.query.JpaEntityMetadata; + +import org.jspecify.annotations.Nullable; import org.springframework.data.repository.core.EntityInformation; -import org.springframework.lang.Nullable; /** * Extension of {@link EntityInformation} to capture additional JPA specific information about entities. diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupport.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupport.java index 6d8c0ba8dc..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 @@ -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 f635a221a4..347b10fde5 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEvaluationContextExtension.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaEvaluationContextExtension.java @@ -15,6 +15,7 @@ */ package org.springframework.data.jpa.repository.support; +import org.jspecify.annotations.Nullable; import org.springframework.data.jpa.repository.query.EscapeCharacter; import org.springframework.data.spel.spi.EvaluationContextExtension; @@ -66,7 +67,7 @@ public static JpaRootObject of(EscapeCharacter character) { * @return * @see EscapeCharacter#escape(String) */ - public String escape(String source) { + public @Nullable String escape(@Nullable String source) { return character.escape(source); } diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaMetamodelEntityInformation.java index 4f337709cc..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 @@ -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); @@ -215,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"); @@ -312,8 +335,7 @@ public Class getType() { return this.idType; } - @Nullable - private Class tryExtractIdTypeWithFallbackToIdTypeLookup() { + private @Nullable Class tryExtractIdTypeWithFallbackToIdTypeLookup() { try { @@ -330,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 aaaff2050c..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 @@ -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/JpaRepositoryFactory.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactory.java index e14658773b..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 @@ -15,8 +15,6 @@ */ package org.springframework.data.jpa.repository.support; -import static org.springframework.data.querydsl.QuerydslUtils.*; - import jakarta.persistence.EntityManager; import jakarta.persistence.Tuple; @@ -27,41 +25,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.ReturnedType; import org.springframework.data.repository.query.ValueExpressionDelegate; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ReflectionUtils; @@ -82,12 +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; @@ -101,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); @@ -123,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); @@ -167,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}. * @@ -179,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)}. @@ -226,11 +240,16 @@ protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { } @Override - protected ProjectionFactory getProjectionFactory(ClassLoader classLoader, BeanFactory beanFactory) { + protected ProjectionFactory getProjectionFactory(@Nullable ClassLoader classLoader, + @Nullable BeanFactory beanFactory) { CollectionAwareProjectionFactory factory = new CollectionAwareProjectionFactory(); - factory.setBeanClassLoader(classLoader); - factory.setBeanFactory(beanFactory); + if (classLoader != null) { + factory.setBeanClassLoader(classLoader); + } + if (beanFactory != null) { + factory.setBeanFactory(beanFactory); + } return factory; } @@ -238,59 +257,54 @@ protected ProjectionFactory getProjectionFactory(ClassLoader classLoader, BeanFa @Override protected Optional getQueryLookupStrategy(@Nullable Key key, ValueExpressionDelegate valueExpressionDelegate) { - return Optional.of(JpaQueryLookupStrategy.create(entityManager, queryMethodFactory, key, - new CachingValueExpressionDelegate(valueExpressionDelegate), - 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 86f2f14d6c..30461fcabb 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryBean.java @@ -18,17 +18,24 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.BeanFactory; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.data.jpa.repository.query.EscapeCharacter; import org.springframework.data.jpa.repository.query.JpaQueryMethodFactory; +import org.springframework.data.jpa.repository.query.QueryEnhancerSelector; import org.springframework.data.mapping.context.MappingContext; import org.springframework.data.querydsl.EntityPathResolver; import org.springframework.data.querydsl.SimpleEntityPathResolver; import org.springframework.data.repository.Repository; import org.springframework.data.repository.core.support.RepositoryFactorySupport; import org.springframework.data.repository.core.support.TransactionalRepositoryFactoryBeanSupport; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -45,10 +52,13 @@ public class JpaRepositoryFactoryBean, 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/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/Querydsl.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/Querydsl.java index 62155b8f0b..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 @@ -25,7 +25,6 @@ import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.mapping.PropertyPath; import org.springframework.data.querydsl.QSort; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import com.querydsl.core.types.EntityPath; @@ -87,10 +86,10 @@ public AbstractJPAQuery> createQuery() { * Obtains the {@link JPQLTemplates} for the configured {@link EntityManager}. Can return {@literal null} to use the * default templates. * - * @return the {@link JPQLTemplates} for the configured {@link EntityManager} or {@literal null} to use the default. + * @return the {@link JPQLTemplates} for the configured {@link EntityManager}, {@link JPQLTemplates#DEFAULT} by + * default. * @since 3.5 */ - @Nullable public JPQLTemplates getTemplates() { return switch (provider) { diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslContributor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslContributor.java new file mode 100644 index 0000000000..280ac954c3 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslContributor.java @@ -0,0 +1,78 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.support; + +import static org.springframework.data.querydsl.QuerydslUtils.*; + +import jakarta.persistence.EntityManager; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.querydsl.EntityPathResolver; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.repository.core.support.RepositoryFragment; +import org.springframework.data.repository.core.support.RepositoryFragmentsContributor; + +/** + * JPA-specific {@link RepositoryFragmentsContributor} contributing Querydsl fragments if a repository implements + * {@link QuerydslPredicateExecutor}. + * + * @author Mark Paluch + * @since 4.0 + * @see QuerydslJpaPredicateExecutor + */ +enum QuerydslContributor implements JpaRepositoryFragmentsContributor { + + INSTANCE; + + @Override + public RepositoryComposition.RepositoryFragments contribute(RepositoryMetadata metadata, + JpaEntityInformation entityInformation, EntityManager entityManager, EntityPathResolver resolver) { + + if (isQuerydslRepository(metadata)) { + + if (metadata.isReactiveRepository()) { + throw new InvalidDataAccessApiUsageException( + "Cannot combine Querydsl and reactive repository support in a single interface"); + } + + QuerydslJpaPredicateExecutor executor = new QuerydslJpaPredicateExecutor<>(entityInformation, entityManager, + resolver, null); + + return RepositoryComposition.RepositoryFragments + .of(RepositoryFragment.implemented(QuerydslJpaPredicateExecutor.class, executor)); + } + + return RepositoryComposition.RepositoryFragments.empty(); + } + + @Override + public RepositoryComposition.RepositoryFragments describe(RepositoryMetadata metadata) { + + if (isQuerydslRepository(metadata)) { + return RepositoryComposition.RepositoryFragments + .of(RepositoryFragment.structural(QuerydslJpaPredicateExecutor.class, QuerydslJpaPredicateExecutor.class)); + } + + return RepositoryComposition.RepositoryFragments.empty(); + } + + private static boolean isQuerydslRepository(RepositoryMetadata metadata) { + return QUERY_DSL_PRESENT && QuerydslPredicateExecutor.class.isAssignableFrom(metadata.getRepositoryInterface()); + } + +} diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutor.java index b37a6e0209..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 @@ -23,6 +23,8 @@ import java.util.function.BiFunction; import java.util.function.Function; +import org.jspecify.annotations.Nullable; + import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.KeysetScrollPosition; @@ -44,7 +46,6 @@ import org.springframework.data.repository.query.FluentQuery; import org.springframework.data.repository.query.FluentQuery.FetchableFluentQuery; import org.springframework.data.support.PageableExecutionUtils; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; import com.querydsl.core.NonUniqueResultException; @@ -180,7 +181,8 @@ 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); @@ -262,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; @@ -297,8 +326,7 @@ protected JPQLQuery createCountQuery(@Nullable Predicate... predicate) { return doCreateQuery(getQueryHintsForCount(), predicate); } - @Nullable - private CrudMethodMetadata getRepositoryMethodMetadata() { + private @Nullable CrudMethodMetadata getRepositoryMethodMetadata() { return metadata; } @@ -375,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 129d56f6e9..0000000000 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepository.java +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright 2008-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.support; - -import java.io.Serializable; -import java.util.List; -import java.util.Optional; -import java.util.function.Function; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.LockModeType; - -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.querydsl.EntityPathResolver; -import org.springframework.data.querydsl.QSort; -import org.springframework.data.querydsl.QuerydslPredicateExecutor; -import org.springframework.data.querydsl.SimpleEntityPathResolver; -import org.springframework.data.repository.query.FluentQuery; -import org.springframework.data.support.PageableExecutionUtils; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; - -import com.querydsl.core.NonUniqueResultException; -import com.querydsl.core.types.EntityPath; -import com.querydsl.core.types.OrderSpecifier; -import com.querydsl.core.types.Predicate; -import com.querydsl.core.types.dsl.PathBuilder; -import com.querydsl.jpa.JPQLQuery; -import com.querydsl.jpa.impl.AbstractJPAQuery; - -/** - * QueryDsl specific extension of {@link SimpleJpaRepository} which adds implementation for - * {@link QuerydslPredicateExecutor}. - * - * @author Oliver Gierke - * @author Thomas Darimont - * @author Mark Paluch - * @author Jocelyn Ntakpe - * @author Christoph Strobl - * @author Jens Schauder - * @author Greg Turnquist - * @author Yanming Zhou - * @deprecated Instead of this class use {@link QuerydslJpaPredicateExecutor} - */ -@Deprecated -public class QuerydslJpaRepository 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 09c43e198b..562b2ad25d 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupport.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/QuerydslRepositorySupport.java @@ -16,10 +16,11 @@ package org.springframework.data.jpa.repository.support; import jakarta.annotation.PostConstruct; +import org.jspecify.annotations.Nullable; + import jakarta.persistence.EntityManager; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.lang.Nullable; import org.springframework.stereotype.Repository; import org.springframework.util.Assert; @@ -84,8 +85,7 @@ public void validate() { * * @return the entityManager */ - @Nullable - protected EntityManager getEntityManager() { + protected @Nullable EntityManager getEntityManager() { return entityManager; } @@ -145,8 +145,7 @@ protected 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 9f649069c2..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 @@ -19,30 +19,31 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; -import jakarta.persistence.NoResultException; -import jakarta.persistence.Parameter; import jakarta.persistence.Query; import jakarta.persistence.TypedQuery; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaDelete; import jakarta.persistence.criteria.CriteriaQuery; -import jakarta.persistence.criteria.ParameterExpression; +import jakarta.persistence.criteria.CriteriaUpdate; import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Root; import jakarta.persistence.criteria.Selection; -import java.io.Serial; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; 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 org.jspecify.annotations.Nullable; + import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Example; import org.springframework.data.domain.KeysetScrollPosition; @@ -53,7 +54,9 @@ import org.springframework.data.domain.ScrollPosition; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.convert.QueryByExamplePredicateBuilder; +import org.springframework.data.jpa.domain.DeleteSpecification; import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.domain.UpdateSpecification; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.query.EscapeCharacter; @@ -71,9 +74,10 @@ 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; @@ -101,6 +105,7 @@ * @author Diego Krupitza * @author Seol-JY * @author Joshua Chen + * @author Giheon Do */ @Repository @Transactional(readOnly = true) @@ -118,6 +123,9 @@ public class SimpleJpaRepository implements JpaRepositoryImplementation deleteAllQueryString; + private final Lazy countQueryString; + private @Nullable CrudMethodMetadata metadata; private ProjectionFactory projectionFactory; private EscapeCharacter escapeCharacter = EscapeCharacter.DEFAULT; @@ -137,6 +145,12 @@ public SimpleJpaRepository(JpaEntityInformation entityInformation, EntityM 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())); } /** @@ -170,8 +184,7 @@ public void setProjectionFactory(ProjectionFactory projectionFactory) { this.projectionFactory = projectionFactory; } - @Nullable - protected CrudMethodMetadata getRepositoryMethodMetadata() { + protected @Nullable CrudMethodMetadata getRepositoryMethodMetadata() { return metadata; } @@ -179,16 +192,6 @@ 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()); - } - @Override @Transactional public void deleteById(ID id) { @@ -205,13 +208,18 @@ public void delete(T entity) { 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; + return true; } Class type = ProxyUtils.getUserClass(entity); @@ -220,7 +228,11 @@ public void delete(T entity) { T existing = (T) entityManager.find(type, entityInformation.getId(entity)); if (existing != null) { entityManager.remove(entityManager.merge(entity)); + + return true; } + + return false; } @Override @@ -253,7 +265,7 @@ 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); @@ -307,7 +319,7 @@ public void deleteAll() { @Transactional public void deleteAllInBatch() { - Query query = entityManager.createQuery(getDeleteAllQueryString()); + Query query = entityManager.createQuery(deleteAllQueryString.get()); applyQueryHints(query); @@ -399,7 +411,7 @@ public boolean existsById(ID id) { @Override public List findAll() { - return getQuery(null, Sort.unsorted()).getResultList(); + return getQuery(Specification.unrestricted(), Sort.unsorted()).getResultList(); } @Override @@ -424,30 +436,29 @@ public List findAllById(Iterable ids) { Collection idCollection = toCollection(ids); - ByIdsSpecification specification = new ByIdsSpecification<>(entityInformation); - TypedQuery query = getQuery(specification, Sort.unsorted()); + TypedQuery query = getQuery((root, q, criteriaBuilder) -> { + + Path path = root.get(entityInformation.getIdAttribute()); + return path.in(idCollection); + + }, 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) { - 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 @@ -456,7 +467,7 @@ public List findAll(Specification spec) { } @Override - public Page findAll(@Nullable Specification spec, Pageable pageable) { + public Page findAll(Specification spec, Pageable pageable) { return findAll(spec, spec, pageable); } @@ -469,13 +480,15 @@ public Page findAll(@Nullable Specification spec, @Nullable Specification< } @Override - public List findAll(@Nullable Specification spec, Sort sort) { + public List findAll(Specification spec, Sort sort) { return getQuery(spec, sort).getResultList(); } @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)); @@ -488,25 +501,24 @@ public boolean exists(Specification spec) { @Override @Transactional - public long delete(@Nullable Specification spec) { + public long update(UpdateSpecification spec) { - CriteriaBuilder builder = this.entityManager.getCriteriaBuilder(); - CriteriaDelete delete = builder.createCriteriaDelete(getDomainClass()); + Assert.notNull(spec, "Specification must not be null"); - if (spec != null) { - Predicate predicate = spec.toPredicate(delete.from(getDomainClass()), builder.createQuery(getDomainClass()), - builder); + return getUpdate(spec, getDomainClass()).executeUpdate(); + } - if (predicate != null) { - delete.where(predicate); - } - } + @Override + @Transactional + public long delete(DeleteSpecification spec) { - return this.entityManager.createQuery(delete).executeUpdate(); + Assert.notNull(spec, "Specification must not be null"); + + return getDelete(spec, getDomainClass()).executeUpdate(); } @Override - public R findBy(Specification spec, + public R findBy(Specification spec, Function, R> queryFunction) { Assert.notNull(spec, SPECIFICATION_MUST_NOT_BE_NULL); @@ -515,6 +527,7 @@ public R findBy(Specification spec, return doFindBy(spec, getDomainClass(), queryFunction); } + @SuppressWarnings("unchecked") private R doFindBy(Specification spec, Class domainClass, Function, R> queryFunction) { @@ -564,13 +577,10 @@ private R doFindBy(Specification spec, Class domainClass, @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 @@ -615,7 +625,9 @@ 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, EXAMPLE_MUST_NOT_BE_NULL); Assert.notNull(queryFunction, QUERY_FUNCTION_MUST_NOT_BE_NULL); @@ -629,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); @@ -637,7 +649,7 @@ public long count() { } @Override - public long count(@Nullable Specification spec) { + public long count(Specification spec) { return executeCountQuery(getCountQuery(spec, getDomainClass())); } @@ -706,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); } @@ -716,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}. */ + @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()); @@ -734,7 +749,7 @@ protected Page readPage(TypedQuery query, Class domainCla /** * Creates a new {@link TypedQuery} from the given {@link Specification}. * - * @param spec can be {@literal null}. + * @param spec must not be {@literal null}. * @param pageable must not be {@literal null}. */ protected TypedQuery getQuery(@Nullable Specification spec, Pageable pageable) { @@ -744,29 +759,28 @@ protected TypedQuery getQuery(@Nullable Specification spec, Pageable pagea /** * Creates a new {@link TypedQuery} from the given {@link Specification}. * - * @param spec can be {@literal null}. + * @param spec must not be {@literal null}. * @param domainClass must not be {@literal null}. * @param pageable must not be {@literal null}. */ - protected TypedQuery getQuery(@Nullable Specification spec, Class domainClass, - Pageable pageable) { + 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}. */ @@ -788,6 +802,8 @@ protected TypedQuery getQuery(@Nullable Specification spec, 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; @@ -817,11 +833,18 @@ private TypedQuery getQuery(ReturnedType returnedType, @Nullabl } List> selections = new ArrayList<>(); - + Set topLevelProperties = new HashSet<>(); for (String property : requiredSelection) { - PropertyPath path = PropertyPath.from(property, returnedType.getDomainType()); - selections.add(QueryUtils.toExpressionRecursively(root, path, true).alias(property)); + 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(); @@ -841,24 +864,62 @@ private TypedQuery getQuery(ReturnedType returnedType, @Nullabl return applyRepositoryMethodMetadata(entityManager.createQuery(query)); } + /** + * Creates a {@link Query} for the given {@link UpdateSpecification}. + * + * @param spec must not be {@literal null}. + * @param domainClass must not be {@literal null}. + */ + protected 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); @@ -892,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) { @@ -935,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) { @@ -982,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())); } } @@ -1019,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 { - - @Serial 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}. @@ -1057,12 +1114,8 @@ public Predicate toPredicate(Root root, CriteriaQuery query, CriteriaBuild * @author Christoph Strobl * @since 1.10 */ - private static class ExampleSpecification implements Specification { - - @Serial 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}. @@ -1070,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(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 index 2ee289253a..d5d518d004 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/SpringDataJpaQuery.java @@ -22,8 +22,6 @@ import java.util.Collection; import java.util.Map; -import org.springframework.lang.Nullable; - import com.querydsl.core.QueryModifiers; import com.querydsl.core.types.Expression; import com.querydsl.core.types.FactoryExpression; @@ -31,6 +29,7 @@ import com.querydsl.jpa.JPQLTemplates; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAUtil; +import org.jspecify.annotations.Nullable; /** * Customized String-Query implementation that specifically routes tuple query creation to diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/package-info.java index 2f75e71375..c40a1ae92f 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/package-info.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/repository/support/package-info.java @@ -1,5 +1,5 @@ /** * JPA repository implementations. */ -@org.springframework.lang.NonNullApi +@org.jspecify.annotations.NullMarked package org.springframework.data.jpa.repository.support; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessor.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessor.java index 324c37f327..686d2ab7ed 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessor.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/ClasspathScanningPersistenceUnitPostProcessor.java @@ -26,6 +26,8 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.EnvironmentAware; import org.springframework.context.ResourceLoaderAware; @@ -39,7 +41,6 @@ import org.springframework.core.io.support.ResourcePatternResolver; import org.springframework.core.io.support.ResourcePatternUtils; import org.springframework.core.type.filter.AnnotationTypeFilter; -import org.springframework.lang.Nullable; import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo; import org.springframework.orm.jpa.persistenceunit.PersistenceUnitPostProcessor; import org.springframework.util.Assert; @@ -193,7 +194,7 @@ private Set scanForMappingFileLocations() { * @param uri * @return */ - private static String getResourcePath(URI uri) throws IOException { + private static String getResourcePath(URI uri) { if (uri.isOpaque()) { // e.g. jar:file:/foo/lib/somelib.jar!/com/acme/orm.xml diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/package-info.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/package-info.java index ad7b5e7f45..6e60ae77b4 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/package-info.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/support/package-info.java @@ -1,5 +1,5 @@ /** * Various helper classes useful when working with JPA. */ -@org.springframework.lang.NonNullApi +@org.jspecify.annotations.NullMarked package org.springframework.data.jpa.support; diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/BeanDefinitionUtils.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/BeanDefinitionUtils.java index a5c181ee0b..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 @@ -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)); + } } /** diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/HibernateProxyDetector.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/HibernateProxyDetector.java index 149742c0b7..2caa4ea9a8 100644 --- a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/HibernateProxyDetector.java +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/HibernateProxyDetector.java @@ -18,8 +18,9 @@ import java.util.Optional; import org.hibernate.proxy.HibernateProxy; +import org.jspecify.annotations.Nullable; + import org.springframework.data.util.ProxyUtils.ProxyDetector; -import org.springframework.lang.Nullable; import org.springframework.util.ClassUtils; /** @@ -40,8 +41,7 @@ public Class getUserType(Class type) { .orElse(type); } - @Nullable - private static Class loadHibernateProxyType() { + private static @Nullable Class loadHibernateProxyType() { try { return ClassUtils.forName("org.hibernate.proxy.HibernateProxy", HibernateProxyDetector.class.getClassLoader()); diff --git a/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/TupleBackedMap.java b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/TupleBackedMap.java new file mode 100644 index 0000000000..1c6c6927f7 --- /dev/null +++ b/spring-data-jpa/src/main/java/org/springframework/data/jpa/util/TupleBackedMap.java @@ -0,0 +1,219 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.util; + +import jakarta.persistence.Tuple; +import jakarta.persistence.TupleElement; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +import org.jspecify.annotations.Nullable; + +import org.springframework.jdbc.support.JdbcUtils; + +/** + * A {@link Map} implementation which delegates all calls to a {@link Tuple}. Depending on the provided {@link Tuple} + * implementation it might return the same value for various keys of which only one will appear in the key/entry set. + * + * @author Jens Schauder + * @since 4.0 + */ +public class TupleBackedMap implements Map { + + 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 7c18f5d466..489f29326e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/AntlrVersionTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/AntlrVersionTests.java @@ -22,6 +22,7 @@ import org.antlr.v4.runtime.RuntimeMetaData; import org.hibernate.grammars.hql.HqlParser; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.springframework.asm.ClassReader; @@ -29,7 +30,6 @@ import org.springframework.asm.MethodVisitor; import org.springframework.asm.Opcodes; import org.springframework.data.jpa.util.DisabledOnHibernate; -import org.springframework.lang.Nullable; /** * Test to verify that we use the same Antlr version as Hibernate. We parse {@code org.hibernate.grammars.hql.HqlParser} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/convert/threeten/Jsr310JpaConvertersUnitTests.java index a1daf39d27..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 @@ -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 dac929f40d..c64d55f7f7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/JpaSortTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/JpaSortTests.java @@ -23,6 +23,7 @@ import jakarta.persistence.metamodel.Attribute; import jakarta.persistence.metamodel.PluralAttribute; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -30,7 +31,6 @@ import org.springframework.data.jpa.domain.sample.MailMessage_; import org.springframework.data.jpa.domain.sample.MailSender_; import org.springframework.data.jpa.domain.sample.User_; -import org.springframework.lang.Nullable; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/PredicateSpecificationUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/PredicateSpecificationUnitTests.java new file mode 100644 index 0000000000..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 eba6ed8851..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 @@ -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,98 +42,19 @@ * @author Jens Schauder * @author Mark Paluch * @author Daniel Shuy + * @author Heeeun Cho + * @author Peter Aisher */ -@SuppressWarnings("removal") +@SuppressWarnings({ "unchecked", "deprecation" }) @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) 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 - void allOfConcatenatesNull() { - - Specification specification = Specification.allOf(null, spec, null); - - assertThat(specification).isNotNull(); - assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate); - } - - @Test // GH-1943 - void anyOfConcatenatesNull() { - - Specification specification = Specification.anyOf(null, spec, null); - - assertThat(specification).isNotNull(); - assertThat(specification.toPredicate(root, query, builder)).isEqualTo(predicate); - } - @Test // GH-1943 void emptyAllOfReturnsEmptySpecification() { @@ -163,7 +81,6 @@ void specificationsShouldBeSerializable() { assertThat(specification).isNotNull(); - @SuppressWarnings({ "unchecked", "deprecation" }) Specification transferredSpecification = (Specification) deserialize(serialize(specification)); assertThat(transferredSpecification).isNotNull(); @@ -178,7 +95,6 @@ void complexSpecificationsShouldBeSerializable() { assertThat(specification).isNotNull(); - @SuppressWarnings({ "unchecked", "deprecation" }) 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); @@ -213,15 +128,40 @@ void orCombinesSpecificationsInOrder() { verify(builder).or(firstPredicate, secondPredicate); } - @Test // GH-3849 + @Test // GH-3849, GH-4023 void notWithNullPredicate() { - when(builder.disjunction()).thenReturn(mock(Predicate.class)); + Specification notSpec = Specification.not(Specification.unrestricted()); + + assertThat(notSpec.toPredicate(root, query, builder)).isNull(); + verifyNoInteractions(builder); + } + + @Test // GH-3992 + void whereWithSpecificationReturnsSameSpecification() { - Specification notSpec = Specification.not((r, q, cb) -> null); + 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); + } - assertThat(notSpec.toPredicate(root, query, builder)).isNotNull(); - verify(builder).disjunction(); + @Test // GH-3992 + void whereWithNullSpecificationThrowsException() { + assertThatIllegalArgumentException().isThrownBy(() -> Specification.where((Specification) null)); } static class SerializableSpecification implements Serializable, Specification { 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/AuditableUser.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableUser.java index 65d4e6e2ad..59b561968f 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableUser.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/AuditableUser.java @@ -24,7 +24,8 @@ import java.util.Set; import org.springframework.data.jpa.domain.AbstractAuditable; -import org.springframework.lang.Nullable; + +import org.jspecify.annotations.Nullable; /** * Sample auditable user to demonstrate working with {@code AbstractAuditableEntity}. No declaration of an ID is diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/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 dd8a85fce2..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-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,21 +13,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/Customer.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/Customer.java index f8442a9ae4..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 @@ -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/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/User.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java index fafd6fca4a..d4027be5ba 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/User.java @@ -60,6 +60,8 @@ @NamedQueries({ // @NamedQuery(name = "User.findByEmailAddress", // query = "SELECT u FROM User u WHERE u.emailAddress = ?1"), // + @NamedQuery(name = "User.findByEmailAddress.count-provided", // + query = "SELECT count(u) FROM User u WHERE u.emailAddress = ?1"), // @NamedQuery(name = "User.findByNamedQueryWithAliasInInvertedOrder", // query = "SELECT u.lastname AS lastname, u.firstname AS firstname FROM User u ORDER BY u.lastname ASC"), @NamedQuery(name = "User.findByNamedQueryWithConstructorExpression", diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserSpecifications.java index 304dcb5607..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 @@ -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 cdfb9a3bfc..2833123509 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserWithOptionalField.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/domain/sample/UserWithOptionalField.java @@ -21,7 +21,7 @@ import java.util.Optional; -import org.springframework.lang.Nullable; +import org.jspecify.annotations.Nullable; /** * @author Greg Turnquist diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/EclipseLinkMetamodelIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/EclipseLinkMetamodelIntegrationTests.java index e3cf795046..d62094bbf8 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/EclipseLinkMetamodelIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/EclipseLinkMetamodelIntegrationTests.java @@ -20,7 +20,7 @@ import org.springframework.test.context.ContextConfiguration; /** - * Metamodel tests using OpenJPA. + * Metamodel tests using Eclipselink. * * @author Oliver Gierke */ diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/OpenJpaMetamodelIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/OpenJpaMetamodelIntegrationTests.java deleted file mode 100644 index 16983f0f88..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/infrastructure/OpenJpaMetamodelIntegrationTests.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2013-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.infrastructure; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.test.context.ContextConfiguration; - -/** - * Metamodel tests using OpenJPA. - * - * @author Oliver Gierke - */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaMetamodelIntegrationTests extends MetamodelIntegrationTests { - - @Test - @Disabled - @Override - void canAccessParametersByIndexForNativeQueries() {} - - /** - * TODO: Remove once https://issues.apache.org/jira/browse/OPENJPA-2618 is fixed. - */ - @Test - @Disabled - @Override - void doesNotExposeAliasForTupleIfNoneDefined() {} -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/provider/PersistenceProviderUnitTests.java index ba7c3abed7..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 @@ -16,23 +16,28 @@ 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 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; @@ -42,6 +47,7 @@ * @author Thomas Darimont * @author Oliver Gierke * @author Jens Schauder + * @author Mark Paluch */ class PersistenceProviderUnitTests { @@ -56,12 +62,32 @@ void setup() { 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); } @@ -70,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 { @@ -105,13 +138,23 @@ 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) @@ -160,4 +203,24 @@ private static String[] toResourcePaths(Class... interfacesToImplement) { .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; + } + + @Override + protected EntityManagerFactory createEntityManagerFactoryProxy(EntityManagerFactory emf) { + return super.createEntityManagerFactoryProxy(emf); + } + } } 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 52e217bb71..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 @@ -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/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/EclipseLinkUserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/EclipseLinkUserRepositoryFinderTests.java index 8593c1ed3e..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 @@ -38,6 +38,6 @@ void executesInKeywordForPageCorrectly() {} @Disabled @Override - void rawMapProjectionWithEntityAndAggregatedValue() {} + void shouldProjectWithKeysetScrolling() {} } 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/EclipseLinkUserRepositoryProjectionTests.java similarity index 73% 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/EclipseLinkUserRepositoryProjectionTests.java index d1e1b01f66..d4d6e2a14f 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/EclipseLinkUserRepositoryProjectionTests.java @@ -19,15 +19,14 @@ import org.springframework.test.context.ContextConfiguration; /** - * 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 Greg Turnquist */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaUserRepositoryFinderTests extends UserRepositoryFinderTests { +@ContextConfiguration("classpath:eclipselink-h2.xml") +class EclipseLinkUserRepositoryProjectionTests extends UserRepositoryProjectionTests { @Disabled @Override - void findsByLastnameIgnoringCaseLike() {} + 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/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/JavaConfigUserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/JavaConfigUserRepositoryTests.java index d87b9e152c..f40877701e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/JavaConfigUserRepositoryTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/JavaConfigUserRepositoryTests.java @@ -15,14 +15,15 @@ */ package org.springframework.data.jpa.repository; -import java.io.IOException; -import java.util.Collections; - import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; +import java.io.IOException; +import java.util.Collections; + import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; + import org.springframework.beans.factory.NoSuchBeanDefinitionException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.PropertiesFactoryBean; @@ -42,8 +43,7 @@ import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; import org.springframework.data.repository.core.NamedQueries; import org.springframework.data.repository.core.support.PropertiesBasedNamedQueries; -import org.springframework.data.repository.query.ExtensionAwareQueryMethodEvaluationContextProvider; -import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; import org.springframework.data.spel.spi.EvaluationContextExtension; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.support.AnnotationConfigContextLoader; @@ -72,7 +72,7 @@ public EvaluationContextExtension sampleEvaluationContextExtension() { @Bean public UserRepository userRepository() throws Exception { - QueryMethodEvaluationContextProvider evaluationContextProvider = new ExtensionAwareQueryMethodEvaluationContextProvider( + ExtensionAwareEvaluationContextProvider evaluationContextProvider = new ExtensionAwareEvaluationContextProvider( applicationContext); JpaRepositoryFactoryBean factory = new JpaRepositoryFactoryBean<>( diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaEntityGraphRepositoryMethodsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaEntityGraphRepositoryMethodsIntegrationTests.java deleted file mode 100644 index c42ae99579..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaEntityGraphRepositoryMethodsIntegrationTests.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright 2014-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository; - -import org.springframework.test.context.ContextConfiguration; - -/** - * @author Oliver Gierke - */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaEntityGraphRepositoryMethodsIntegrationTests extends EntityGraphRepositoryMethodsIntegrationTests {} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaNamespaceUserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaNamespaceUserRepositoryTests.java deleted file mode 100644 index a69fb9e35c..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaNamespaceUserRepositoryTests.java +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2008-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.TypedQuery; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.CriteriaQuery; -import jakarta.persistence.criteria.ParameterExpression; -import jakarta.persistence.criteria.Root; - -import java.util.Arrays; -import java.util.Collection; -import java.util.List; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.springframework.data.jpa.domain.sample.User; -import org.springframework.data.jpa.repository.sample.UserRepository; -import org.springframework.test.context.ContextConfiguration; - -/** - * Testcase to run {@link UserRepository} integration tests on top of OpenJPA. - * - * @author Oliver Gierke - * @author Jens Schauder - * @author Krzysztof Krason - */ -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaNamespaceUserRepositoryTests extends NamespaceUserRepositoryTests { - - @PersistenceContext EntityManager em; - - @Test - void checkQueryValidationWithOpenJpa() { - - assertThatThrownBy(() -> em.createQuery("something absurd")).isInstanceOf(RuntimeException.class); - assertThatThrownBy(() -> em.createNamedQuery("not available")).isInstanceOf(RuntimeException.class); - } - - /** - * Test case for https://issues.apache.org/jira/browse/OPENJPA-2018 - */ - @SuppressWarnings({ "rawtypes" }) - @Test - @Disabled - void queryUsingIn() { - - flushTestUsers(); - - CriteriaBuilder builder = em.getCriteriaBuilder(); - - CriteriaQuery 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 6984b99e27..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaStoredProcedureIntegrationTests.java +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2015-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository; - -import org.junit.jupiter.api.Disabled; -import org.springframework.context.annotation.ImportResource; -import org.springframework.test.context.ContextConfiguration; - -/** - * Test case to run {@link StoredProcedureIntegrationTests} integration tests on top of OpenJpa. This is currently not - * supported since, the OpenJPA tests need to be executed with hsqldb1 which doesn't supported stored procedures. - * - * @author Thomas Darimont - * @author Oliver Gierke - */ -@Disabled -@ContextConfiguration(classes = { StoredProcedureIntegrationTests.Config.class }) -class OpenJpaStoredProcedureIntegrationTests extends StoredProcedureIntegrationTests { - - @ImportResource({ "classpath:infrastructure.xml", "classpath:openjpa.xml" }) - static class TestConfig extends Config {} -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/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/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/RepositoryWithCompositeKeyTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/RepositoryWithCompositeKeyTests.java index 20613cc1d6..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 @@ -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/SimpleJpaParameterBindingTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SimpleJpaParameterBindingTests.java index efe754ad7b..bad8461741 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SimpleJpaParameterBindingTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/SimpleJpaParameterBindingTests.java @@ -15,7 +15,7 @@ */ package org.springframework.data.jpa.repository; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -32,6 +32,7 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.data.jpa.domain.sample.User; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; @@ -45,7 +46,6 @@ @ExtendWith(SpringExtension.class) @ContextConfiguration({ "classpath:application-context.xml" // , "classpath:eclipselink.xml" -// , "classpath:openjpa.xml" }) @Transactional class SimpleJpaParameterBindingTests { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryFinderTests.java index 7eb4e4cc6c..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 @@ -22,15 +22,11 @@ import java.util.Arrays; 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.dao.InvalidDataAccessApiUsageException; @@ -42,18 +38,12 @@ import org.springframework.data.domain.Slice; import org.springframework.data.domain.Sort; import org.springframework.data.domain.Window; -import org.springframework.data.jpa.domain.sample.Address; import org.springframework.data.jpa.domain.sample.Role; import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.provider.PersistenceProvider; 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; @@ -350,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() { @@ -381,22 +348,6 @@ void rejectsStreamExecutionIfNoSurroundingTransactionActive() { .isThrownBy(() -> userRepository.findAllByCustomQueryAndStream()); } - @Test // DATAJPA-1334 - void executesNamedQueryWithConstructorExpression() { - userRepository.findByNamedQueryWithConstructorExpression(); - } - - @Test // DATAJPA-1713, GH-2008 - void selectProjectionWithSubselect() { - - List dtos = userRepository.findProjectionBySubselect(); - - assertThat(dtos).flatExtracting(UserRepository.NameOnly::getFirstname) // - .containsExactly("Dave", "Carter", "Oliver August"); - assertThat(dtos).flatExtracting(UserRepository.NameOnly::getLastname) // - .containsExactly("Matthews", "Beauford", "Matthews"); - } - @Test // GH-3675 void findBySimplePropertyUsingMixedNullNonNullArgument() { @@ -415,120 +366,12 @@ void findByNegatingSimplePropertyUsingMixedNullNonNullArgument() { assertThat(result).containsExactly(carter); } - @Test // GH-3076 - void dtoProjectionShouldApplyConstructorExpressionRewriting() { - - List dtos = userRepository.findRecordProjection(); - - assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // - .contains("Dave", "Carter", "Oliver August"); - - dtos = userRepository.findRecordProjectionWithFunctions(); - - assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::lastname) // - .contains("matthews", "beauford"); - } - - @Test // GH-3076 - void dtoMultiselectProjectionShouldApplyConstructorExpressionRewriting() { - - List dtos = userRepository.findMultiselectRecordProjection(); - - assertThat(dtos).flatExtracting(UserRepository.UserExcerpt::firstname) // - .contains("Dave", "Carter", "Oliver August"); - } - - @Test // GH-3076 - void dynamicDtoProjection() { - - List dtos = userRepository.findRecordProjection(UserExcerpt.class); - - assertThat(dtos).flatExtracting(UserRepository.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) { + @Test // GH-3857 + void shouldApplyParameterNames() { - assertThat(userRepository.findMultiselectRecordDynamicProjection(resultType)).hasSize(3) - .hasOnlyElementsOfType(resultType); + 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/UserRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/UserRepositoryTests.java index 9ebddf394b..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 @@ -20,8 +20,6 @@ import static org.springframework.data.domain.Example.*; import static org.springframework.data.domain.ExampleMatcher.*; import static org.springframework.data.domain.Sort.Direction.*; -import static org.springframework.data.jpa.domain.Specification.*; -import static org.springframework.data.jpa.domain.Specification.not; import static org.springframework.data.jpa.domain.sample.UserSpecifications.*; import jakarta.persistence.EntityManager; @@ -48,8 +46,8 @@ import org.assertj.core.api.SoftAssertions; import org.hibernate.LazyInitializationException; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -62,7 +60,11 @@ import org.springframework.data.domain.*; import org.springframework.data.domain.Sort.Direction; import org.springframework.data.domain.Sort.Order; +import org.springframework.data.jpa.domain.DeleteSpecification; +import org.springframework.data.jpa.domain.JpaSort; +import org.springframework.data.jpa.domain.PredicateSpecification; import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.domain.UpdateSpecification; import org.springframework.data.jpa.domain.sample.Address; import org.springframework.data.jpa.domain.sample.QUser; import org.springframework.data.jpa.domain.sample.Role; @@ -73,7 +75,6 @@ import org.springframework.data.jpa.repository.sample.UserRepository; import org.springframework.data.jpa.repository.sample.UserRepository.NameOnly; import org.springframework.data.jpa.util.DisabledOnHibernate; -import org.springframework.lang.Nullable; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.transaction.annotation.Transactional; @@ -469,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 @@ -499,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); @@ -516,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); } @@ -525,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); @@ -591,7 +593,7 @@ void executesSimpleNotCorrectly() { void returnsSameListIfNoSpecGiven() { flushTestUsers(); - assertSameElements(repository.findAll(), repository.findAll((Specification) null)); + assertSameElements(repository.findAll(), repository.findAll(PredicateSpecification.unrestricted())); } @Test @@ -607,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 removesAllIfSpecificationIsNull() { + void predicateSpecificationRemovesAll() { flushTestUsers(); - repository.delete((Specification) null); + repository.delete(DeleteSpecification.unrestricted()); + + assertThat(repository.count()).isEqualTo(0L); + } + + @Test // GH-2796 + void deleteSpecificationRemovesAll() { + + flushTestUsers(); + + repository.delete(DeleteSpecification.unrestricted()); assertThat(repository.count()).isEqualTo(0L); } @@ -775,9 +803,6 @@ void executesFinderWithFalseKeywordCorrectly() { assertThat(repository.findByActiveFalse()).containsOnly(firstUser); } - /** - * Ignored until the query declaration is supported by OpenJPA. - */ @Test void executesAnnotatedCollectionMethodCorrectly() { @@ -1557,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() { @@ -1566,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 @@ -1590,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"); @@ -2838,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() { @@ -3169,6 +3242,38 @@ void handlesColonsFollowedByIntegerInStringLiteral() { assertThat(users).extracting(User::getId).containsExactly(expected.getId()); } + @Test // GH-3172 + void specificationShouldApplyUnsafeSort() { + + flushTestUsers(); + firstUser.setManager(firstUser); + secondUser.setManager(firstUser); + thirdUser.setManager(secondUser); + fourthUser.setManager(secondUser); + repository.saveAllAndFlush(List.of(firstUser, secondUser, thirdUser, fourthUser)); + + PredicateSpecification 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() { @@ -3318,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); @@ -3386,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(); @@ -3412,7 +3526,7 @@ void deleteWithSpec() { flushTestUsers(); - Specification usersWithEInTheirName = userHasFirstnameLike("e"); + PredicateSpecification usersWithEInTheirName = userHasFirstnameLike("e"); long initialCount = repository.count(); assertThat(repository.delete(usersWithEInTheirName)).isEqualTo(3L); @@ -3559,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); @@ -3583,7 +3697,7 @@ private interface UserProjectionInterfaceBased { String getLastname(); } - record UserDto(Integer id, String firstname, String lastname, String emailAddress) { + public record UserDto(Integer id, String firstname, String lastname, String emailAddress) { } 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/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/OpenJpaParentRepositoryIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QuerydslUserRepository.java similarity index 55% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaParentRepositoryIntegrationTests.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QuerydslUserRepository.java index d94ed598c0..6c551c482d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/OpenJpaParentRepositoryIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/QuerydslUserRepository.java @@ -1,5 +1,5 @@ /* - * Copyright 2013-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,15 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.jpa.repository; +package org.springframework.data.jpa.repository.aot; -import org.junit.jupiter.api.Disabled; -import org.springframework.test.context.ContextConfiguration; +import java.util.List; -@ContextConfiguration("classpath:openjpa.xml") -class OpenJpaParentRepositoryIntegrationTests extends ParentRepositoryIntegrationTests { +import org.springframework.data.jpa.domain.sample.User; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.data.repository.CrudRepository; + +interface QuerydslUserRepository extends CrudRepository, QuerydslPredicateExecutor { + + List findUserNoArgumentsBy(); - @Override - @Disabled - void testWithJoin() {} } 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/aot/UserDtoProjection.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserDtoProjection.java new file mode 100644 index 0000000000..3e8e974500 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/aot/UserDtoProjection.java @@ -0,0 +1,39 @@ +/* + * 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; + +/** + * @author Christoph Strobl + * @since 2025/01 + */ +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; + } + + 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/config/AbstractAuditingViaJavaConfigRepositoriesTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractAuditingViaJavaConfigRepositoriesTests.java index 5285ed2e3e..3073cc420e 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractAuditingViaJavaConfigRepositoriesTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/config/AbstractAuditingViaJavaConfigRepositoriesTests.java @@ -20,9 +20,9 @@ import jakarta.persistence.EntityManager; +import java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; -import java.util.Date; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -54,6 +54,7 @@ * @author Oliver Gierke * @author Jens Schauder * @author Krzysztof Krason + * @author Christoph Strobl */ @ExtendWith(SpringExtension.class) @Transactional @@ -111,13 +112,13 @@ void shouldAllowUseOfDynamicSpelParametersInUpdateQueries() { em.detach(thomas); em.detach(auditor); - FixedDate.INSTANCE.setDate(new Date()); + FixedDate.INSTANCE.setDate(Instant.now()); SampleSecurityContextHolder.getCurrent().setPrincipal(thomas); auditableUserRepository.updateAllNamesToUpperCase(); // DateTime now = new DateTime(FixedDate.INSTANCE.getDate()); - LocalDateTime now = LocalDateTime.ofInstant(FixedDate.INSTANCE.getDate().toInstant(), ZoneId.systemDefault()); + LocalDateTime now = LocalDateTime.ofInstant(FixedDate.INSTANCE.getDate(), ZoneId.systemDefault()); List users = auditableUserRepository.findAll(); for (AuditableUser user : users) { 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 df4e2097e5..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 @@ -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 714abc2afa..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 @@ -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/procedures/MySqlStoredProcedureIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/MySqlStoredProcedureIntegrationTests.java index 5366736fc9..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 @@ -23,10 +23,12 @@ import jakarta.persistence.Id; import jakarta.persistence.NamedStoredProcedureQuery; +import java.net.URL; import java.util.List; import java.util.Objects; import org.hibernate.dialect.MySQLDialect; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,6 +40,8 @@ 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.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; @@ -223,12 +227,33 @@ 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") public MySQLContainer container() { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/PostgresStoredProcedureIntegrationTests.java index af07eb0013..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 @@ -26,11 +26,13 @@ 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 org.hibernate.dialect.PostgreSQLDialect; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -42,7 +44,9 @@ 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.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.annotation.EnableTransactionManagement; @@ -106,7 +110,8 @@ void testNamedOutputParameter() { new Employee(4, "Gabriel")); } - @DisabledOnHibernate("6") + @DisabledOnHibernate(value = "7", + disabledReason = "class org.hibernate.metamodel.model.domain.internal.EntityTypeImpl cannot be cast to class org.hibernate.query.OutputableType (org.hibernate.metamodel.model.domain.internal.EntityTypeImpl and org.hibernate.query.OutputableType are in unnamed module of loader 'app')") @Test // 2256 void testSingleEntityFromResultSet() { @@ -291,12 +296,33 @@ public interface EmployeeRepositoryWithRefCursor 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") public PostgreSQLContainer container() { 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 c5fba7eff7..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 @@ -20,10 +20,13 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import java.net.URL; import java.util.Date; +import java.util.List; import java.util.UUID; import org.hibernate.dialect.PostgreSQLDialect; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -36,7 +39,9 @@ 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.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.annotation.EnableTransactionManagement; @@ -128,12 +133,33 @@ public interface TestModelRepository extends JpaRepository { @EnableJpaRepositories(considerNestedRepositories = true, includeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = TestModelRepository.class)) @EnableTransactionManagement - static class Config extends StoredProcedureConfigSupport { + static class Config extends TestcontainerConfigSupport { public Config() { super(PostgreSQLDialect.class, new ClassPathResource("scripts/postgres-nullable-stored-procedures.sql")); } + @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; + } + }; + + } + @SuppressWarnings("resource") @Bean(initMethod = "start", destroyMethod = "stop") public PostgreSQLContainer container() { 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 8728e03229..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 @@ -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 6590db4022..3d77980fb6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryIntegrationTests.java @@ -34,7 +34,6 @@ import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.provider.PersistenceProvider; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.Repository; import org.springframework.data.repository.core.support.DefaultRepositoryMetadata; @@ -53,6 +52,9 @@ @ContextConfiguration("classpath:infrastructure.xml") class AbstractStringBasedJpaQueryIntegrationTests { + private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(), + QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT); + @PersistenceContext EntityManager em; @Autowired BeanFactory beanFactory; @@ -66,10 +68,10 @@ void createsNormalQueryForJpaManagedReturnTypes() throws Exception { when(mock.getMetamodel()).thenReturn(em.getMetamodel()); JpaQueryMethod method = getMethod("findRolesByEmailAddress", String.class); - AbstractStringBasedJpaQuery jpaQuery = new SimpleJpaQuery(method, mock, null, QueryRewriter.IdentityQueryRewriter.INSTANCE, - ValueExpressionDelegate.create()); + AbstractStringBasedJpaQuery jpaQuery = new SimpleJpaQuery(method, mock, method.getRequiredDeclaredQuery(), null, + CONFIG); - jpaQuery.createJpaQuery(method.getAnnotatedQuery(), Sort.unsorted(), null, + jpaQuery.createJpaQuery(method.getRequiredDeclaredQuery(), Sort.unsorted(), null, method.getResultProcessor().getReturnedType()); verify(mock, times(1)).createQuery(anyString()); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/AbstractStringBasedJpaQueryUnitTests.java index 3fb97409f8..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 @@ -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,6 +28,7 @@ import org.assertj.core.api.Assertions; import org.assertj.core.util.Arrays; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.mockito.Mockito; @@ -35,7 +37,6 @@ import org.springframework.data.domain.Sort; import org.springframework.data.jpa.provider.QueryExtractor; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.Repository; import org.springframework.data.repository.core.RepositoryMetadata; @@ -43,7 +44,6 @@ import org.springframework.data.repository.query.ParametersSource; import org.springframework.data.repository.query.ReturnedType; import org.springframework.data.repository.query.ValueExpressionDelegate; -import org.springframework.lang.Nullable; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.util.ReflectionUtils; @@ -53,9 +53,13 @@ * * @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() { @@ -118,8 +122,8 @@ static InvocationCapturingStringQueryStub forMethod(Class repository, String Query query = AnnotatedElementUtils.getMergedAnnotation(respositoryMethod, Query.class); - return new InvocationCapturingStringQueryStub(respositoryMethod, queryMethod, query.value(), query.countQuery()); - + return new InvocationCapturingStringQueryStub(respositoryMethod, queryMethod, query.value(), query.countQuery(), + CONFIG); } static class InvocationCapturingStringQueryStub extends AbstractStringBasedJpaQuery { @@ -128,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) { + @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), - ValueExpressionDelegate.create()); + }.get(), queryString, countQueryString, queryConfiguration); this.targetMethod = targetMethod; } @Override - protected String applySorting(CachableQuery query) { + protected QueryProvider applySorting(CachableQuery query) { captureInvocation("applySorting", query); @@ -157,12 +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/StringQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java similarity index 82% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StringQueryUnitTests.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java index beb206724d..a4fd297beb 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/StringQueryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultEntityQueryUnitTests.java @@ -17,7 +17,6 @@ import static org.assertj.core.api.Assertions.*; -import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -32,7 +31,7 @@ import org.springframework.data.repository.query.parser.Part.Type; /** - * Unit tests for {@link StringQuery}. + * Unit tests for {@link DefaultEntityQuery}. * * @author Oliver Gierke * @author Thomas Darimont @@ -44,13 +43,13 @@ * @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); @@ -67,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()) @@ -91,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(); @@ -117,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"); @@ -134,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); @@ -165,7 +165,7 @@ void rewritesNamedLikeToUniqueParametersIfNecessary() { @Test // GH-3784 void rewritesNamedLikeToUniqueParametersRetainingCountQuery() { - DeclaredQuery query = new StringQuery( + ParametrizedQuery query = new TestEntityQuery( "select u from User u where u.firstname like %:firstname or u.firstname like :firstname% or u.firstname = :firstname", false).deriveCountQuery(null); @@ -198,7 +198,7 @@ void rewritesNamedLikeToUniqueParametersRetainingCountQuery() { @Test // GH-3784 void rewritesExpressionsLikeToUniqueParametersRetainingCountQuery() { - DeclaredQuery query = new StringQuery( + ParametrizedQuery query = new TestEntityQuery( "select u from User u where u.firstname like %:#{firstname} or u.firstname like :#{firstname}%", false) .deriveCountQuery(null); @@ -225,7 +225,7 @@ void rewritesExpressionsLikeToUniqueParametersRetainingCountQuery() { @Test // GH-3041 void rewritesPositionalLikeToUniqueParametersIfNecessary() { - StringQuery query = new StringQuery( + DefaultEntityQuery query = new TestEntityQuery( "select u from User u where u.firstname like %?1 or u.firstname like ?1% or u.firstname = ?1", true); assertThat(query.hasParameterBindings()).isTrue(); @@ -236,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); @@ -247,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()) @@ -257,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); @@ -265,7 +281,7 @@ void reusesPositionalLikeBindingsWherePossible() { assertThat(query.getQueryString()).isEqualTo( "select u from User u where u.firstname like ?1 or u.firstname like ?2 or u.firstname like ?2 or u.firstname like ?1"); - query = new StringQuery("select u from User u where u.firstname like %?1 or u.firstname =?1", false); + query = new TestEntityQuery("select u from User u where u.firstname like %?1 or u.firstname =?1", false); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like ?1 or u.firstname =?2"); @@ -274,7 +290,7 @@ void reusesPositionalLikeBindingsWherePossible() { @Test // GH-3041 void shouldRewritePositionalBindingsWithParameterReuse() { - StringQuery query = new StringQuery( + DefaultEntityQuery query = new TestEntityQuery( "select u from User u where u.firstname like ?2 or u.firstname like %?2% or u.firstname like %?1% or u.firstname like %?1 OR u.firstname like ?1", false); @@ -296,8 +312,8 @@ void shouldRewritePositionalBindingsWithParameterReuse() { @Test // GH-3758 void createsDistinctBindingsForIndexedSpel() { - StringQuery query = new StringQuery("select u from User u where u.firstname = ?#{foo} OR u.firstname = ?#{foo}", - false); + DefaultEntityQuery query = new TestEntityQuery( + "select u from User u where u.firstname = ?#{foo} OR u.firstname = ?#{foo}", false); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getParameterBindings()).hasSize(2).extracting(ParameterBinding::getRequiredPosition) @@ -310,8 +326,8 @@ void createsDistinctBindingsForIndexedSpel() { @Test // GH-3758 void createsDistinctBindingsForNamedSpel() { - StringQuery query = new StringQuery("select u from User u where u.firstname = :#{foo} OR u.firstname = :#{foo}", - false); + DefaultEntityQuery query = new TestEntityQuery( + "select u from User u where u.firstname = :#{foo} OR u.firstname = :#{foo}", false); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getParameterBindings()).hasSize(2).extracting(ParameterBinding::getOrigin) @@ -323,7 +339,7 @@ void createsDistinctBindingsForNamedSpel() { void detectsNamedInParameterBindings() { String queryString = "select u from User u where u.id in :ids"; - StringQuery query = new StringQuery(queryString, true); + DefaultEntityQuery query = new TestEntityQuery(queryString, true); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo(queryString); @@ -338,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); @@ -355,7 +371,7 @@ void detectsMultipleNamedInParameterBindings() { void deriveCountQueryWithNamedInRetainsOrigin() { String queryString = "select u from User u where (:logins) IS NULL OR LOWER(u.login) IN (:logins)"; - DeclaredQuery query = new StringQuery(queryString, false).deriveCountQuery(null); + ParametrizedQuery query = new TestEntityQuery(queryString, false).deriveCountQuery(null); assertThat(query.getQueryString()) .isEqualTo("select count(u) from User u where (:logins) IS NULL OR LOWER(u.login) IN (:logins_1)"); @@ -376,7 +392,7 @@ void deriveCountQueryWithNamedInRetainsOrigin() { void deriveCountQueryWithPositionalInRetainsOrigin() { String queryString = "select u from User u where (?1) IS NULL OR LOWER(u.login) IN (?1)"; - DeclaredQuery query = new StringQuery(queryString, false).deriveCountQuery(null); + ParametrizedQuery query = new TestEntityQuery(queryString, false).deriveCountQuery(null); assertThat(query.getQueryString()) .isEqualTo("select count(u) from User u where (?1) IS NULL OR LOWER(u.login) IN (?2)"); @@ -397,7 +413,7 @@ void deriveCountQueryWithPositionalInRetainsOrigin() { void detectsPositionalInParameterBindings() { String queryString = "select u from User u where u.id in ?1"; - StringQuery query = new StringQuery(queryString, true); + DefaultEntityQuery query = new TestEntityQuery(queryString, true); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo(queryString); @@ -411,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(); @@ -424,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); @@ -443,7 +459,7 @@ void allowsReuseOfParameterWithInAndRegularBinding() { void detectsPositionalInParameterBindingsAndExpressions() { String queryString = "select u from User u where foo = ?#{bar} and bar = ?3 and baz = ?#{baz}"; - StringQuery query = new StringQuery(queryString, true); + DefaultEntityQuery query = new TestEntityQuery(queryString, true); assertThat(query.getQueryString()).isEqualTo("select u from User u where foo = ?1 and bar = ?3 and baz = ?2"); } @@ -452,7 +468,7 @@ void detectsPositionalInParameterBindingsAndExpressions() { void detectsPositionalInParameterBindingsAndExpressionsWithReuse() { String queryString = "select u from User u where foo = ?#{bar} and bar = ?2 and baz = ?#{bar}"; - StringQuery query = new StringQuery(queryString, true); + DefaultEntityQuery query = new TestEntityQuery(queryString, true); assertThat(query.getQueryString()).isEqualTo("select u from User u where foo = ?1 and bar = ?2 and baz = ?3"); } @@ -460,17 +476,17 @@ void detectsPositionalInParameterBindingsAndExpressionsWithReuse() { @Test // GH-3126 void countQueryDerivationRetainsNamedExpressionParameters() { - StringQuery query = new StringQuery( + DefaultEntityQuery query = new TestEntityQuery( "select u from User u where foo = :#{bar} ORDER BY CASE WHEN (u.firstname >= :#{name}) THEN 0 ELSE 1 END", false); - DeclaredQuery countQuery = query.deriveCountQuery(null); + ParametrizedQuery countQuery = query.deriveCountQuery(null); assertThat(countQuery.getParameterBindings()).hasSize(1); assertThat(countQuery.getParameterBindings()).extracting(ParameterBinding::getOrigin) .extracting(ParameterOrigin::isExpression).isEqualTo(List.of(true)); - query = new StringQuery( + query = new TestEntityQuery( "select u from User u where foo = :#{bar} and bar = :bar ORDER BY CASE WHEN (u.firstname >= :bar) THEN 0 ELSE 1 END", false); @@ -485,17 +501,17 @@ void countQueryDerivationRetainsNamedExpressionParameters() { @Test // GH-3126 void countQueryDerivationRetainsIndexedExpressionParameters() { - StringQuery query = new StringQuery( + DefaultEntityQuery query = new TestEntityQuery( "select u from User u where foo = ?#{bar} ORDER BY CASE WHEN (u.firstname >= ?#{name}) THEN 0 ELSE 1 END", false); - DeclaredQuery countQuery = query.deriveCountQuery(null); + ParametrizedQuery countQuery = query.deriveCountQuery(null); assertThat(countQuery.getParameterBindings()).hasSize(1); assertThat(countQuery.getParameterBindings()).extracting(ParameterBinding::getOrigin) .extracting(ParameterOrigin::isExpression).isEqualTo(List.of(true)); - query = new StringQuery( + query = new TestEntityQuery( "select u from User u where foo = ?#{bar} and bar = ?1 ORDER BY CASE WHEN (u.firstname >= ?1) THEN 0 ELSE 1 END", false); @@ -511,7 +527,7 @@ void countQueryDerivationRetainsIndexedExpressionParameters() { void detectsMultiplePositionalInParameterBindings() { String queryString = "select u from User u where u.id in ?1 and u.names in ?2 and foo = ?3"; - StringQuery query = new StringQuery(queryString, true); + DefaultEntityQuery query = new TestEntityQuery(queryString, true); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo(queryString); @@ -527,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(); @@ -560,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(); @@ -571,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(); @@ -582,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(); @@ -593,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(); @@ -604,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(); @@ -615,7 +633,7 @@ void detectsInBindingWithSpecialCharactersAndWordCharactersMixedInParentheses() @Test // DATAJPA-712, GH-3619 void shouldReplaceAllNamedExpressionParametersWithInClause() { - StringQuery query = new StringQuery( + DefaultEntityQuery query = new TestEntityQuery( "select a from A a where a.b in :#{#bs} and a.c in :#{#cs} and a.d in :${foo.bar}", true); String queryString = query.getQueryString(); @@ -626,7 +644,7 @@ void shouldReplaceAllNamedExpressionParametersWithInClause() { @Test // DATAJPA-712 void shouldReplaceExpressionWithLikeParameters() { - StringQuery query = new StringQuery( + DefaultEntityQuery query = new TestEntityQuery( "select a from A a where a.b LIKE :#{#filter.login}% and a.c LIKE %:#{#filter.login}", true); String queryString = query.getQueryString(); @@ -634,11 +652,25 @@ void shouldReplaceExpressionWithLikeParameters() { .isEqualTo("select a from A a where a.b LIKE :__$synthetic$__1 and a.c LIKE :__$synthetic$__2"); } + @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} and a.d in ?${foo}", - true); + DefaultEntityQuery query = new TestEntityQuery( + "select a from A a where a.b in ?#{#bs} and a.c in ?#{#cs} and a.d in ?${foo}", true); String queryString = query.getQueryString(); assertThat(queryString).isEqualTo("select a from A a where a.b in ?1 and a.c in ?2 and a.d in ?3"); @@ -654,12 +686,11 @@ void shouldReplaceAllPositionExpressionParametersWithInClause() { @Test // DATAJPA-864 void detectsConstructorExpressions() { - assertThat( - new StringQuery("select new com.example.Dto(a.foo, a.bar) from A a", false).hasConstructorExpression()) - .isTrue(); - assertThat(new StringQuery("select new com.example.Dto (a.foo, a.bar) from A a", false).hasConstructorExpression()) - .isTrue(); - assertThat(new StringQuery("select a from A a", true).hasConstructorExpression()).isFalse(); + assertThat(new TestEntityQuery("select new com.example.Dto(a.foo, a.bar) from A a", false) + .hasConstructorExpression()).isTrue(); + assertThat(new TestEntityQuery("select new com.example.Dto (a.foo, a.bar) from A a", false) + .hasConstructorExpression()).isTrue(); + assertThat(new TestEntityQuery("select a from A a", true).hasConstructorExpression()).isFalse(); } /** @@ -670,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(); @@ -704,7 +737,7 @@ void getProjection() { void checkProjection(String query, String expected, String description, boolean nativeQuery) { - assertThat(new StringQuery(query, nativeQuery).getProjection()) // + assertThat(new TestEntityQuery(query, nativeQuery).getProjection()) // .as("%s (%s)", description, query) // .isEqualTo(expected); } @@ -728,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); } @@ -781,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(); @@ -801,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( // @@ -821,7 +856,7 @@ void makesUsageOfJdbcStyleParameterAvailable() { for (String testQuery : testQueries) { - assertThat(new StringQuery(testQuery, false) // + assertThat(new TestEntityQuery(testQuery, false) // .usesJdbcStyleParameters()) // .describedAs(testQuery) // .describedAs(testQuery) // @@ -833,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(); @@ -853,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(); } @@ -870,7 +905,7 @@ void isNotDefaultProjection() { ); for (String queryString : queriesWithDefaultProjection) { - assertThat(new StringQuery(queryString, true).isDefaultProjection()) // + assertThat(new TestEntityQuery(queryString, true).isDefaultProjection()) // .describedAs(queryString) // .isTrue(); } @@ -880,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(); @@ -892,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) // @@ -903,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) // @@ -912,23 +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) { - List bindings = new ArrayList<>(); - StringQuery.ParameterBindingParser.INSTANCE.parseParameterBindingsOfQueryIntoBindingsAndReturnCleanedQuery(query, - bindings, new StringQuery.Metadata()); + DeclaredQuery source = DeclaredQuery.jpqlQuery(query); + PreprocessedQuery bindableQuery = PreprocessedQuery.ParameterBindingParser.INSTANCE.parse(query, + source::rewrite, it -> {}); - assertThat(bindings.stream().anyMatch(it -> it.getIdentifier().hasName())) // + assertThat(bindableQuery.getBindings().stream().anyMatch(it -> it.getIdentifier().hasName())) // .describedAs(String.format("<%s> (%s)", query, label)) // .isEqualTo(expected); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancerUnitTests.java index 6b9c4e2478..7dd6dd757c 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancerUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/DefaultQueryEnhancerUnitTests.java @@ -21,6 +21,8 @@ import org.junit.jupiter.api.Test; import org.springframework.data.domain.Sort; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.query.ReturnedType; /** * TCK Tests for {@link DefaultQueryEnhancer}. @@ -31,8 +33,8 @@ class DefaultQueryEnhancerUnitTests extends QueryEnhancerTckTests { @Override - QueryEnhancer createQueryEnhancer(DeclaredQuery declaredQuery) { - return new DefaultQueryEnhancer(declaredQuery); + QueryEnhancer createQueryEnhancer(DeclaredQuery query) { + return new DefaultQueryEnhancer(query); } @Override @@ -43,9 +45,10 @@ void shouldDeriveNativeCountQueryWithVariable(String query, String expected) {} @Test // GH-3546 void shouldApplySorting() { - QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.of("SELECT e FROM Employee e", true)); + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery("SELECT e FROM Employee e")); - String sql = enhancer.applySorting(Sort.by("foo", "bar")); + String sql = enhancer.rewrite(new DefaultQueryRewriteInformation(Sort.by("foo", "bar"), + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()))); assertThat(sql).isEqualTo("SELECT e FROM Employee e order by e.foo asc, e.bar asc"); } @@ -53,9 +56,11 @@ void shouldApplySorting() { @Test // GH-3811 void shouldApplySortingWithNullHandling() { - QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.of("SELECT e FROM Employee e", true)); + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery("SELECT e FROM Employee e")); - String sql = enhancer.applySorting(Sort.by(Sort.Order.asc("foo").nullsFirst(), Sort.Order.asc("bar").nullsLast())); + String sql = enhancer.rewrite(new DefaultQueryRewriteInformation( + Sort.by(Sort.Order.asc("foo").nullsFirst(), Sort.Order.asc("bar").nullsLast()), + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()))); assertThat(sql).isEqualTo("SELECT e FROM Employee e order by e.foo asc nulls first, e.bar asc nulls last"); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkQueryUtilsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EclipseLinkQueryUtilsIntegrationTests.java index ce1b95d90e..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 @@ -22,11 +22,16 @@ 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}. @@ -63,4 +68,43 @@ void prefersFetchOverJoin() { 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 2ec5f229a1..9b092c7924 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlComplianceTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlComplianceTests.java @@ -18,15 +18,17 @@ import static org.assertj.core.api.Assertions.*; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.data.jpa.repository.query.QueryRenderer.TokenRenderer; /** * Tests built around examples of EQL found in the EclipseLink's docs at * https://wiki.eclipse.org/EclipseLink/UserGuide/JPA/Basic_JPA_Development/Querying/JPQL
      - * 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 @@ -96,6 +98,7 @@ void joinFetch() { assertQuery("SELECT e FROM Employee e JOIN FETCH e.address"); assertQuery("SELECT e FROM Employee e JOIN FETCH e.address a ORDER BY a.city"); + assertQuery("SELECT e FROM Employee e JOIN FETCH e.address AS a ORDER BY a.city"); } @Test @@ -116,6 +119,21 @@ void subselectsInFromClause() { "SELECT e, c.city FROM Employee e, (SELECT DISTINCT a.city FROM Address a) c WHERE e.address.city = c.city"); } + @Test // GH-3277 + void numericLiterals() { + + assertQuery("SELECT e FROM Employee e WHERE e.id = 1234"); + assertQuery("SELECT e FROM Employee e WHERE e.id = 1234L"); + assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14"); + assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14F"); + assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14e32D"); + } + + @Test // GH-3308 + void newWithStrings() { + assertQuery("select new com.example.demo.SampleObject(se.id, se.sampleValue, \"java\") from SampleEntity se"); + } + @Test void orderByClause() { @@ -412,4 +430,53 @@ void isNullAndIsNotNull() { assertQuery("SELECT e FROM Employee e WHERE (e.active IS NOT null OR e.active = true)"); assertQuery("SELECT e FROM Employee e WHERE (e.active IS NOT NULL OR e.active = true)"); } + + @Test // GH-3496 + void lateralShouldBeAValidParameter() { + + assertQuery("select e from Employee e where e.lateral = :_lateral"); + assertQuery("select te from TestEntity te where te.lateral = :lateral"); + } + + @Test // GH-3136 + void intersect() { + + assertQuery(""" + SELECT e FROM Employee e JOIN e.phones p WHERE p.areaCode = :areaCode1 + INTERSECT SELECT e FROM Employee e JOIN e.phones p WHERE p.areaCode = :areaCode2 + """); + } + + @Test // GH-3136 + void except() { + + assertQuery(""" + SELECT e FROM Employee e + EXCEPT SELECT e FROM Employee e WHERE e.salary > e.manager.salary + """); + } + + @ParameterizedTest // GH-3136 + @ValueSource(strings = { "STRING", "INTEGER", "FLOAT", "DOUBLE" }) + void cast(String targetType) { + assertQuery("SELECT CAST(e.salary AS %s) FROM Employee e".formatted(targetType)); + } + + @ParameterizedTest // GH-3136 + @ValueSource(strings = { "LEFT", "RIGHT" }) + void leftRightStringFunctions(String keyword) { + assertQuery("SELECT %s(e.name, 3) FROM Employee e".formatted(keyword)); + } + + @Test // GH-3136 + void replaceStringFunctions() { + assertQuery("SELECT REPLACE(e.name, 'o', 'a') FROM Employee e"); + assertQuery("SELECT REPLACE(e.name, ' ', '_') FROM Employee e"); + } + + @Test // GH-3136 + void stringConcatWithPipes() { + assertQuery("SELECT e.firstname || e.lastname AS name FROM Employee e"); + } + } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java index b8ac4f35a3..967af726e6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlDtoQueryTransformerUnitTests.java @@ -15,102 +15,26 @@ */ package org.springframework.data.jpa.repository.query; -import static org.assertj.core.api.Assertions.*; - -import java.lang.reflect.Method; - -import org.junit.jupiter.api.Test; +import org.antlr.v4.runtime.tree.ParseTreeVisitor; import org.springframework.data.domain.Sort; -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; /** * Unit tests for {@link DtoProjectionTransformerDelegate}. * * @author Mark Paluch */ -class EqlDtoQueryTransformerUnitTests { - - JpaQueryMethod method = getMethod("dtoProjection"); - - @Test // GH-3076 - void shouldTranslateSingleProjectionToDto() { - - JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser.parseQuery("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.EqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar) from Person p"); - } - - @Test // GH-3076 - void shouldRewriteQueriesWithSubselect() { - - JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser - .parseQuery("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.EqlDtoQueryTransformerUnitTests$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 shouldNotTranslateConstructorExpressionQuery() { - - JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser - .parseQuery("SELECT NEW Foo(p) from Person p"); - - QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); - - assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW Foo(p) from Person p"); - } - - @Test - void shouldTranslatePropertySelectionToDto() { - - JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser - .parseQuery("SELECT p.foo, p.bar, sum(p.age) from Person p"); +class EqlDtoQueryTransformerUnitTests extends AbstractDtoQueryTransformerUnitTests { - EqlSortedQueryTransformer transformer = getTransformer(parser); - QueryTokenStream visit = transformer.visit(parser.getContext()); - - assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( - "SELECT new org.springframework.data.jpa.repository.query.EqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar, sum(p.age)) from Person p"); + @Override + JpaQueryEnhancer.EqlQueryParser parse(String query) { + return JpaQueryEnhancer.EqlQueryParser.parseQuery(query); } - 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); - } - } - - private EqlSortedQueryTransformer getTransformer(JpaQueryEnhancer.EqlQueryParser parser) { + @Override + ParseTreeVisitor getTransformer(JpaQueryEnhancer.EqlQueryParser parser, QueryMethod method) { return new EqlSortedQueryTransformer(Sort.unsorted(), parser.getQueryInformation(), method.getResultProcessor().getReturnedType()); } - - interface MyRepo extends Repository { - - MyRecord dtoProjection(); - } - - record Person(String id) { - - } - - record MyRecord(String foo, String bar) { - - } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlParserQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlParserQueryEnhancerUnitTests.java index 8895fc4c19..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 @@ -25,14 +25,14 @@ * * @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 9ad73bae54..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 @@ -69,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 */ @@ -347,6 +622,38 @@ select cast(i as string) from Item i where cast(i.date as date) <= cast(:current 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 void pathExpressionsNamedParametersExample() { @@ -458,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 @@ -952,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() { @@ -1041,6 +1351,59 @@ void powerShouldBeLegalInAQuery() { assertQuery("select e.power.id from MyEntity e"); } + @Test // GH-3136 + void doublePipeShouldBeValidAsAStringConcatOperator() { + + assertQuery(""" + select e.name || ' ' || e.title + from Employee e + """); + } + + @Test // GH-3136 + void combinedSelectStatementsShouldWork() { + + assertQuery(""" + select e from Employee e where e.last_name = 'Baggins' + intersect + select e from Employee e where e.first_name = 'Samwise' + union + select e from Employee e where e.home = 'The Shire' + except + select e from Employee e where e.home = 'Isengard' + """); + } + + @Disabled + @Test // GH-3136 + void additionalStringOperationsShouldWork() { + + assertQuery(""" + select + replace(e.name, 'Baggins', 'Proudfeet'), + left(e.role, 4), + right(e.home, 5), + cast(e.distance_from_home, int) + from Employee e + """); + } + + @Test // GH-3136 + void orderByWithNullsFirstOrLastShouldWork() { + + assertQuery(""" + select a + from Element a + order by mutationAm desc nulls first + """); + + assertQuery(""" + select a + from Element a + order by mutationAm desc nulls last + """); + } + @ParameterizedTest // GH-3342 @ValueSource(strings = { "select 1 from User u", "select -1 from User u", "select +1 from User u", "select +1 * -100 from User u", "select count(u) * -0.7f from User u", @@ -1089,4 +1452,28 @@ void reservedWordsShouldWork() { assertQuery("select f from FooEntity f where upper(f.name) IN :names"); assertQuery("select f from FooEntity f where f.size IN :sizes"); } + + @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 3c1fec2ed3..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 @@ -20,6 +20,7 @@ import java.util.stream.Stream; import org.assertj.core.api.SoftAssertions; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -28,13 +29,15 @@ 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 JpaQueryEnhancer.EqlQueryParser}. * * @author Greg Turnquist + * @author Mark Paluch */ class EqlQueryTransformerTests { @@ -80,13 +83,11 @@ void nullFirstLastSorting() { 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").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"); + assertThat(createQueryFor(original, Sort.by(Order.desc("lastName").nullsFirst()))).startsWith(original) + .endsWithIgnoringCase("e.lastName DESC NULLS FIRST"); } @Test @@ -102,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() { @@ -115,6 +142,12 @@ 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 applyCountToAlreadySortedQuery() { @@ -141,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); } @@ -181,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"); @@ -191,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"); @@ -208,6 +255,11 @@ void detectsAliasCorrectly() { 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"); + 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 @@ -221,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 @@ -639,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() { @@ -694,6 +734,36 @@ void countQueryUsesCorrectVariable() { .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 void orderByShouldWorkWithSubSelectStatements() { @@ -803,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) { @@ -827,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 bff45ec75d..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/EqlSpecificationTests.java +++ /dev/null @@ -1,905 +0,0 @@ -/* - * Copyright 2023-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.query; - -import static org.assertj.core.api.Assertions.*; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import org.springframework.data.jpa.repository.query.QueryRenderer.TokenRenderer; - -/** - * Tests built around examples of EQL found in the JPA spec - * https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc
      - *
      - * IMPORTANT: Purely verifies the parser without any transformations. - * - * @author Greg Turnquist - */ -class EqlSpecificationTests { - - private static final String SPEC_FAULT = "Disabled due to spec fault> "; - - private static String parseWithoutChanges(String query) { - - JpaQueryEnhancer.EqlQueryParser parser = JpaQueryEnhancer.EqlQueryParser.parseQuery(query); - - return TokenRenderer.render(new EqlQueryRenderer().visit(parser.getContext())); - } - - private void assertQuery(String query) { - - String slimmedDownQuery = reduceWhitespace(query); - assertThat(parseWithoutChanges(slimmedDownQuery)).isEqualTo(slimmedDownQuery); - } - - private String reduceWhitespace(String original) { - - return original // - .replaceAll("[ \\t\\n]{1,}", " ") // - .trim(); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example - */ - @Test - void joinExample1() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order AS o JOIN o.lineItems AS l - WHERE l.shipped = FALSE - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#identification-variables - */ - @Test - void joinExample2() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l JOIN l.product p - WHERE p.productType = 'office_supplies' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#range-variable-declarations - */ - @Test - void rangeVariableDeclarations() { - - assertQuery(""" - SELECT DISTINCT o1 - FROM Order o1, Order o2 - WHERE o1.quantity > o2.quantity AND - o2.customer.lastname = 'Smith' AND - o2.customer.firstname = 'John' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample1() { - - assertQuery(""" - SELECT i.name, VALUE(p) - FROM Item i JOIN i.photos p - WHERE KEY(p) LIKE '%egret' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample2() { - - assertQuery(""" - SELECT i.name, p - FROM Item i JOIN i.photos p - WHERE KEY(p) LIKE '%egret' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample3() { - - assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo.phones p - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample4() { - - assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE e.contactInfo.address.zipcode = '95054' - """); - } - - @Test - void pathExpressionSyntaxExample1() { - - assertQuery(""" - SELECT DISTINCT l.product - FROM Order AS o JOIN o.lineItems l - """); - } - - @Test - void joinsExample1() { - - assertQuery(""" - SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize - """); - } - - @Test - void joinsExample2() { - - assertQuery(""" - SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInnerExample() { - - assertQuery(""" - SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInExample() { - - assertQuery(""" - SELECT OBJECT(c) FROM Customer c, IN(c.orders) o WHERE c.status = 1 - """); - } - - @Test - void doubleJoinExample() { - - assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE c.address.zipcode = '95054' - """); - } - - @Test - void leftJoinExample() { - - assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - GROUP BY s.name - """); - } - - @Test - void leftJoinOnExample() { - - assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - ON p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinWhereExample() { - - assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - WHERE p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinFetchExample() { - - assertQuery(""" - SELECT d - FROM Department d LEFT JOIN FETCH d.employees - WHERE d.deptno = 1 - """); - } - - @Test - void collectionMemberExample() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void collectionMemberInExample() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o, IN(o.lineItems) l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void fromClauseExample() { - - assertQuery(""" - SELECT o - FROM Order AS o JOIN o.lineItems l JOIN l.product p - """); - } - - @Test - void fromClauseDowncastingExample1() { - - assertQuery(""" - SELECT b.name, b.ISBN - FROM Order o JOIN TREAT(o.product AS Book) b - """); - } - - @Test - void fromClauseDowncastingExample2() { - - assertQuery(""" - SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp - WHERE lp.budget > 1000 - """); - } - - /** - * @see #fromClauseDowncastingExample3fixed() - */ - @Test - @Disabled(SPEC_FAULT + "Use double-quotes when it should be using single-quotes for a string literal") - void fromClauseDowncastingExample3_SPEC_BUG() { - - assertQuery(""" - SELECT e FROM Employee e JOIN e.projects p - WHERE TREAT(p AS LargeProject).budget > 1000 - OR TREAT(p AS SmallProject).name LIKE 'Persist%' - OR p.description LIKE "cost overrun" - """); - } - - @Test - void fromClauseDowncastingExample3fixed() { - - assertQuery(""" - SELECT e FROM Employee e JOIN e.projects p - WHERE TREAT(p AS LargeProject).budget > 1000 - OR TREAT(p AS SmallProject).name LIKE 'Persist%' - OR p.description LIKE 'cost overrun' - """); - } - - @Test - void fromClauseDowncastingExample4() { - - assertQuery(""" - SELECT e FROM Employee e - WHERE TREAT(e AS Exempt).vacationDays > 10 - OR TREAT(e AS Contractor).hours > 100 - """); - } - - @Test - void pathExpressionsNamedParametersExample() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.status = :stat - """); - } - - @Test - void betweenExpressionsExample() { - - assertQuery(""" - SELECT t - FROM CreditCard c JOIN c.transactionHistory t - WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9 - """); - } - - @Test - void isEmptyExample() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void memberOfExample() { - - assertQuery(""" - SELECT p - FROM Person p - WHERE 'Joe' MEMBER OF p.nicknames - """); - } - - @Test - void existsSubSelectExample1() { - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void allExample() { - - assertQuery(""" - SELECT emp - FROM Employee emp - WHERE emp.salary > ALL (SELECT m.salary - FROM Manager m - WHERE m.department = emp.department) - """); - } - - @Test - void existsSubSelectExample2() { - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void subselectNumericComparisonExample1() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE (SELECT AVG(o.price) FROM c.orders o) > 100 - """); - } - - @Test - void subselectNumericComparisonExample2() { - - assertQuery(""" - SELECT goodCustomer - FROM Customer goodCustomer - WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed) / 2.0 FROM Customer c) - """); - } - - @Test - void indexExample() { - - assertQuery(""" - SELECT w.name - FROM Course c JOIN c.studentWaitlist w - WHERE c.name = 'Calculus' - AND INDEX(w) = 0 - """); - } - - /** - * @see #functionInvocationExampleWithCorrection() - */ - @Test - @Disabled(SPEC_FAULT + "FUNCTION calls needs a comparator") - void functionInvocationExample_SPEC_BUG() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) - """); - } - - @Test - void functionInvocationExampleWithCorrection() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE - """); - } - - @Test - void updateCaseExample1() { - - assertQuery(""" - UPDATE Employee e - SET e.salary = - CASE WHEN e.rating = 1 THEN e.salary * 1.1 - WHEN e.rating = 2 THEN e.salary * 1.05 - ELSE e.salary * 1.01 - END - """); - } - - @Test - void updateCaseExample2() { - - assertQuery(""" - UPDATE Employee e - SET e.salary = - CASE e.rating WHEN 1 THEN e.salary * 1.1 - WHEN 2 THEN e.salary * 1.05 - ELSE e.salary * 1.01 - END - """); - } - - @Test - void selectCaseExample1() { - - assertQuery(""" - SELECT e.name, - CASE TYPE(e) WHEN Exempt THEN 'Exempt' - WHEN Contractor THEN 'Contractor' - WHEN Intern THEN 'Intern' - ELSE 'NonExempt' - END - FROM Employee e - WHERE e.dept.name = 'Engineering' - """); - } - - @Test - void selectCaseExample2() { - - assertQuery(""" - SELECT e.name, - f.name, - CONCAT(CASE WHEN f.annualMiles > 50000 THEN 'Platinum ' - WHEN f.annualMiles > 25000 THEN 'Gold ' - ELSE '' - END, - 'Frequent Flyer') - FROM Employee e JOIN e.frequentFlierPlan f - """); - } - - @Test - void theRest() { - - assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (Exempt, Contractor) - """); - } - - @Test - void theRest2() { - - assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (:empType1, :empType2) - """); - } - - @Test - void theRest3() { - - assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN :empTypes - """); - } - - @Test - void theRest4() { - - assertQuery(""" - SELECT TYPE(e) - FROM Employee e - WHERE TYPE(e) <> Exempt - """); - } - - @Test - void theRest5() { - - assertQuery(""" - SELECT c.status, AVG(c.filledOrderCount), COUNT(c) - FROM Customer c - GROUP BY c.status - HAVING c.status IN (1, 2) - """); - } - - @Test - void theRest6() { - - assertQuery(""" - SELECT c.country, COUNT(c) - FROM Customer c - GROUP BY c.country - HAVING COUNT(c) > 30 - """); - } - - @Test - void theRest7() { - - assertQuery(""" - SELECT c, COUNT(o) - FROM Customer c JOIN c.orders o - GROUP BY c - HAVING COUNT(o) >= 5 - """); - } - - @Test - void theRest8() { - - assertQuery(""" - SELECT c.id, c.status - FROM Customer c JOIN c.orders o - WHERE o.count > 100 - """); - } - - @Test - void theRest9() { - - assertQuery(""" - SELECT v.location.street, KEY(i).title, VALUE(i) - FROM VideoStore v JOIN v.videoInventory i - WHERE v.location.zipcode = '94301' AND VALUE(i) > 0 - """); - } - - @Test - void theRest10() { - - assertQuery(""" - SELECT o.lineItems FROM Order AS o - """); - } - - @Test - void theRest11() { - - assertQuery(""" - SELECT c, COUNT(l) AS itemCount - FROM Customer c JOIN c.Orders o JOIN o.lineItems l - WHERE c.address.state = 'CA' - GROUP BY c - ORDER BY itemCount - """); - } - - @Test - void theRest12() { - - assertQuery(""" - SELECT NEW com.acme.example.CustomerDetails(c.id, c.status, o.count) - FROM Customer c JOIN c.orders o - WHERE o.count > 100 - """); - } - - @Test - void theRest13() { - - assertQuery(""" - SELECT e.address AS addr - FROM Employee e - """); - } - - @Test - void theRest14() { - - assertQuery(""" - SELECT AVG(o.quantity) FROM Order o - """); - } - - @Test - void theRest15() { - - assertQuery(""" - SELECT SUM(l.price) - FROM Order o JOIN o.lineItems l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - """); - } - - @Test - void theRest16() { - - assertQuery(""" - SELECT COUNT(o) FROM Order o - """); - } - - @Test - void theRest17() { - - assertQuery(""" - SELECT COUNT(l.price) - FROM Order o JOIN o.lineItems l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - """); - } - - @Test - void theRest18() { - - assertQuery(""" - SELECT COUNT(l) - FROM Order o JOIN o.lineItems l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' AND l.price IS NOT NULL - """); - } - - @Test - void theRest19() { - - assertQuery(""" - SELECT o - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' - ORDER BY o.quantity DESC, o.totalcost - """); - } - - @Test - void theRest20() { - - assertQuery(""" - SELECT o.quantity, a.zipcode - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' - ORDER BY o.quantity, a.zipcode - """); - } - - @Test - void theRest21() { - - assertQuery(""" - SELECT o.quantity, o.cost * 1.08 AS taxedCost, a.zipcode - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' AND a.county = 'Santa Clara' - ORDER BY o.quantity, taxedCost, a.zipcode - """); - } - - @Test - void theRest22() { - - assertQuery(""" - SELECT AVG(o.quantity) as q, a.zipcode - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' - GROUP BY a.zipcode - ORDER BY q DESC - """); - } - - @Test - void theRest23() { - - assertQuery(""" - SELECT p.product_name - FROM Order o JOIN o.lineItems l JOIN l.product p JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - ORDER BY p.price - """); - } - - /** - * This query is specifically dubbed illegal in the spec. It may actually be failing for a different reason. - */ - @Test - void theRest24() { - - assertThatExceptionOfType(BadJpqlGrammarException.class).isThrownBy(() -> { - assertQuery(""" - SELECT p.product_name - FROM Order o, IN(o.lineItems) l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - ORDER BY o.quantity - """); - }); - } - - @Test - void theRest25() { - - assertQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - """); - } - - @Test - void theRest26() { - - assertQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - AND c.orders IS EMPTY - """); - } - - @Test - void theRest27() { - - assertQuery(""" - UPDATE Customer c - SET c.status = 'outstanding' - WHERE c.balance < 10000 - """); - } - - @Test - void theRest28() { - - assertQuery(""" - UPDATE Employee e - SET e.address.building = 22 - WHERE e.address.building = 14 - AND e.address.city = 'Santa Clara' - AND e.project = 'Jakarta EE' - """); - } - - @Test - void theRest29() { - - assertQuery(""" - SELECT o - FROM Order o - """); - } - - @Test - void theRest30() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress.state = 'CA' - """); - } - - @Test - void theRest31() { - - assertQuery(""" - SELECT DISTINCT o.shippingAddress.state - FROM Order o - """); - } - - @Test - void theRest32() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - """); - } - - @Test - void theRest33() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS NOT EMPTY - """); - } - - @Test - void theRest34() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void theRest35() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.shipped = FALSE - """); - } - - @Test - void theRest36() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE - NOT (o.shippingAddress.state = o.billingAddress.state AND - o.shippingAddress.city = o.billingAddress.city AND - o.shippingAddress.street = o.billingAddress.street) - """); - } - - @Test - void theRest37() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress <> o.billingAddress - """); - } - - @Test - void theRest38() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.name = ?1 - """); - } -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/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 index 9afdcd9764..4ac9f6a9c5 100644 --- 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 @@ -15,101 +15,27 @@ */ package org.springframework.data.jpa.repository.query; -import static org.assertj.core.api.Assertions.*; - -import java.lang.reflect.Method; - -import org.junit.jupiter.api.Test; +import org.antlr.v4.runtime.tree.ParseTreeVisitor; import org.springframework.data.domain.Sort; -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; /** * Unit tests for {@link DtoProjectionTransformerDelegate}. * * @author Mark Paluch */ -class HqlDtoQueryTransformerUnitTests { - - JpaQueryMethod method = getMethod("dtoProjection"); - - @Test // GH-3076 - void shouldTranslateSingleProjectionToDto() { - - JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser.parseQuery("SELECT p from Person p"); +class HqlDtoQueryTransformerUnitTests extends AbstractDtoQueryTransformerUnitTests { - QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); - - assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( - "SELECT new org.springframework.data.jpa.repository.query.HqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar) from Person p"); + @Override + JpaQueryEnhancer.HqlQueryParser parse(String query) { + return JpaQueryEnhancer.HqlQueryParser.parseQuery(query); } - @Test // GH-3076 - void shouldRewriteQueriesWithSubselect() { - - JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser - .parseQuery("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.HqlDtoQueryTransformerUnitTests$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 shouldNotTranslateConstructorExpressionQuery() { - - JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser - .parseQuery("SELECT NEW String(p) from Person p"); - - QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); - - assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW String(p) from Person p"); - } - - @Test - void shouldTranslatePropertySelectionToDto() { - - JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser - .parseQuery("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.HqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar, sum(p.age)) 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); - } - } - - private HqlSortedQueryTransformer getTransformer(JpaQueryEnhancer.HqlQueryParser parser) { + @Override + ParseTreeVisitor getTransformer(JpaQueryEnhancer.HqlQueryParser parser, QueryMethod method) { return new HqlSortedQueryTransformer(Sort.unsorted(), parser.getQueryInformation(), method.getResultProcessor().getReturnedType()); } - interface MyRepo extends Repository { - - MyRecord dtoProjection(); - } - - record Person(String id) { - - } - - record MyRecord(String foo, String bar) { - - } } 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 ef7b269115..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 @@ -25,14 +25,14 @@ * * @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/HqlQueryRendererTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlQueryRendererTests.java index 6abc8b5048..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 @@ -5,7 +5,7 @@ * 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, @@ -19,7 +19,6 @@ import java.util.stream.Stream; -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; @@ -27,9 +26,10 @@ 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. * @@ -37,16 +37,15 @@ * @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) { + static String parseWithoutChanges(String query) { JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser.parseQuery(query); @@ -71,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 */ @@ -169,11 +141,11 @@ void pathExpressionSyntaxExample1() { assertQuery(""" SELECT DISTINCT l.product - FROM Order AS o JOIN o.lineItems l + FROM Order AS o JOIN o.lineItems l LEFT JOIN l.product p """); } - @Test // GH-3711 + @Test // GH-3711, GH-2970 void entityTypeReference() { assertQuery(""" @@ -185,6 +157,42 @@ SELECT TYPE(e) SELECT TYPE(?0) FROM Employee e """); + + assertQuery(""" + SELECT e + FROM Employee e + WHERE TYPE(e) IN (Exempt, Contractor) + """); + + assertQuery(""" + SELECT e + FROM Employee e + WHERE TYPE(e) IN (:empType1, :empType2) + """); + + assertQuery(""" + SELECT e + FROM Employee e + WHERE TYPE(e) IN :empTypes + """); + + assertQuery(""" + SELECT TYPE(e) + FROM Employee e + WHERE TYPE(e) <> Exempt + """); + + assertQuery(""" + SELECT TYPE(e) + FROM Employee e + WHERE TYPE(e) != Exempt + """); + + assertQuery(""" + SELECT TYPE(e) + FROM Employee e + WHERE TYPE(e) ^= Exempt + """); } @Test // GH-3711 @@ -347,6 +355,366 @@ SELECT some_function().foo """); } + @ParameterizedTest // GH-3689 + @ValueSource(strings = { "RESPECT NULLS", "IGNORE NULLS" }) + void generic(String nullHandling) { + + // not in the official documentation but supported in the grammar. + assertQuery(""" + SELECT e FROM Employee e + WHERE FOO(x).bar %s + """.formatted(nullHandling)); + } + + @Test // GH-3689 + void size() { + + assertQuery(""" + SELECT e FROM Employee e + WHERE SIZE(x) > 1 + """); + + assertQuery(""" + SELECT e FROM Employee e + WHERE SIZE(e.skills) > 1 + """); + } + + @Test // GH-3689 + void collectionAggregate() { + + assertQuery(""" + SELECT e FROM Employee e + WHERE MAXELEMENT(foo) > MINELEMENT(bar) + """); + + assertQuery(""" + SELECT e FROM Employee e + WHERE MININDEX(foo) > MAXINDEX(bar) + """); + } + + @Test // GH-3689 + void trunc() { + + assertQuery(""" + SELECT e FROM Employee e + WHERE TRUNC(x) = TRUNCATE(y) + """); + + assertQuery(""" + SELECT e FROM Employee e + WHERE TRUNC(e, 'foo') = TRUNCATE(e, 'bar') + """); + + assertQuery(""" + SELECT e FROM Employee e + WHERE TRUNC(e, 'YEAR') = TRUNCATE(LOCAL DATETIME, 'YEAR') + """); + } + + @ParameterizedTest // GH-3689 + @ValueSource(strings = { "YEAR", "MONTH", "DAY", "WEEK", "QUARTER", "HOUR", "MINUTE", "SECOND", "NANOSECOND", + "NANOSECOND", "EPOCH" }) + void trunc(String truncation) { + + assertQuery(""" + SELECT e FROM Employee e + WHERE TRUNC(e, %1$s) = TRUNCATE(e, %1$s) + """.formatted(truncation)); + } + + @Test // GH-3689 + void format() { + + assertQuery(""" + SELECT e FROM Employee e + WHERE FORMAT(x AS 'yyyy') = FORMAT(e.hiringDate AS 'yyyy') + """); + + assertQuery(""" + SELECT e FROM Employee e + WHERE e.hiringDate = format(LOCAL DATETIME as 'yyyy-MM-dd') + """); + + assertQuery(""" + SELECT e FROM Employee e + WHERE e.hiringDate = format(LOCAL_DATE() as 'yyyy-MM-dd') + """); + } + + @Test // GH-3689 + void collate() { + + assertQuery(""" + SELECT e FROM Employee e + WHERE COLLATE(x AS ucs_basic) = COLLATE(e.name AS ucs_basic) + """); + } + + @Test // GH-3689 + void substring() { + + assertQuery("select substring(c.number, 1, 2) " + // + "from Call c"); + + assertQuery("select substring(c.number, 1) " + // + "from Call c"); + + assertQuery("select substring(c.number, 1, position('/0' in c.number)) " + // + "from Call c"); + + assertQuery("select substring(c.number FROM 1 FOR 2) " + // + "from Call c"); + + assertQuery("select substring(c.number FROM 1) " + // + "from Call c"); + + assertQuery("select substring(c.number FROM 1 FOR position('/0' in c.number)) " + // + "from Call c"); + + assertQuery("select substring(c.number FROM 1) AS shortNumber " + // + "from Call c"); + } + + @Test // GH-3689 + void overlay() { + + assertQuery("select OVERLAY(c.number PLACING 1 FROM 2) " + // + "from Call c "); + + assertQuery("select OVERLAY(p.number PLACING 1 FROM 2 FOR 3) " + // + "from Call c "); + } + + @Test // GH-3689 + void pad() { + + assertQuery("select PAD(c.number WITH 1 LEADING) " + // + "from Call c "); + + assertQuery("select PAD(c.number WITH 1 TRAILING) " + // + "from Call c "); + + assertQuery("select PAD(c.number WITH 1 LEADING '0') " + // + "from Call c "); + + assertQuery("select PAD(c.number WITH 1 TRAILING '0') " + // + "from Call c "); + } + + @Test // GH-3689 + void position() { + + assertQuery("select POSITION(c.number IN 'foo') " + // + "from Call c "); + + assertQuery("select POSITION(c.number IN 'foo') + 1 AS pos " + // + "from Call c "); + } + + @Test // GH-3689 + void currentDateFunctions() { + + assertQuery("select CURRENT DATE, CURRENT_DATE() " + // + "from Call c "); + + assertQuery("select CURRENT TIME, CURRENT_TIME() " + // + "from Call c "); + + assertQuery("select CURRENT TIMESTAMP, CURRENT_TIMESTAMP() " + // + "from Call c "); + + assertQuery("select INSTANT, CURRENT_INSTANT() " + // + "from Call c "); + + assertQuery("select LOCAL DATE, LOCAL_DATE() " + // + "from Call c "); + + assertQuery("select LOCAL TIME, LOCAL_TIME() " + // + "from Call c "); + + assertQuery("select LOCAL DATETIME, LOCAL_DATETIME() " + // + "from Call c "); + + assertQuery("select OFFSET DATETIME, OFFSET_DATETIME() " + // + "from Call c "); + + assertQuery("select OFFSET DATETIME AS offsetDatetime, OFFSET_DATETIME() AS offset_datetime " + // + "from Call c "); + } + + @Test // GH-3689 + void cube() { + + assertQuery("select CUBE(foo), CUBE(foo, bar) " + // + "from Call c "); + + assertQuery("select c.callerId from Call c GROUP BY CUBE(state, province)"); + } + + @Test // GH-3689 + void rollup() { + + assertQuery("select ROLLUP(foo), ROLLUP(foo, bar) " + // + "from Call c "); + + assertQuery("select c.callerId from Call c GROUP BY ROLLUP(state, province)"); + } + + @Test + void pathExpressionsNamedParametersExample() { + + assertQuery(""" + SELECT c + FROM Customer c + WHERE c.status = :stat + """); + } + + @Test + void betweenExpressionsExample() { + + assertQuery(""" + SELECT t + FROM CreditCard c JOIN c.transactionHistory t + WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9 + """); + } + + @Test + void isEmptyExample() { + + assertQuery(""" + SELECT o + FROM Order o + WHERE o.lineItems IS EMPTY + """); + } + + @Test + void memberOfExample() { + + assertQuery(""" + SELECT p + FROM Person p + WHERE 'Joe' MEMBER OF p.nicknames + """); + } + + @Test + void existsSubSelectExample1() { + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE EXISTS (SELECT spouseEmp + FROM Employee spouseEmp + WHERE spouseEmp = emp.spouse) + """); + } + + @Test // GH-3689 + void everyAll() { + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE EVERY (SELECT spouseEmp + FROM Employee spouseEmp) > 1 + """); + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE ALL (SELECT spouseEmp + FROM Employee spouseEmp) > 1 + """); + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE ALL (foo > 1) OVER (PARTITION BY bar) + """); + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE ALL VALUES(foo) > 1 + """); + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE ALL ELEMENTS(foo) > 1 + """); + } + + @Test // GH-3689 + void anySome() { + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE ANY (SELECT spouseEmp + FROM Employee spouseEmp) > 1 + """); + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE SOME (SELECT spouseEmp + FROM Employee spouseEmp) > 1 + """); + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE ANY (foo > 1) OVER (PARTITION BY bar) + """); + + assertQuery(""" + SELECT DISTINCT emp + FROM Employee emp + WHERE ANY VALUES(foo) > 1 + """); + } + + @Test // GH-3689 + void listAgg() { + + assertQuery("select listagg(p.number, ', ') within group (order by p.type, p.number) " + // + "from Phone p " + // + "group by p.person"); + } + + /** + * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example + */ + @Test + void joinExample1() { + + assertQuery(""" + SELECT DISTINCT o + FROM Order AS o JOIN o.lineItems AS l + WHERE l.shipped = FALSE + """); + } + + /** + * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example + * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#identification-variables + */ + @Test + void joinExample2() { + + assertQuery(""" + SELECT DISTINCT o + FROM Order o JOIN o.lineItems l JOIN l.product p + WHERE p.productType = 'office_supplies' + """); + } + @Test void joinsExample1() { @@ -371,7 +739,6 @@ void joinsInnerExample() { """); } - @Disabled("Deprecated syntax dating back to EJB-QL prior to EJB 3, required by JPA, never documented in Hibernate") @Test void joinsInExample() { @@ -406,7 +773,7 @@ void leftJoinOnExample() { assertQuery(""" SELECT s.name, COUNT(p) FROM Suppliers s LEFT JOIN s.products p - ON p.status = 'inStock' + ON p.status = 'inStock' GROUP BY s.name """); } @@ -447,7 +814,7 @@ void collectionMemberInExample() { assertQuery(""" SELECT DISTINCT o - FROM Order o , IN(o.lineItems) l + FROM Order o, IN(o.lineItems) l WHERE l.product.productType = 'office_supplies' """); } @@ -483,25 +850,20 @@ SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp * @see #fromClauseDowncastingExample3fixed() */ @Test - @Disabled(SPEC_FAULT + "Use double-quotes when it should be using single-quotes for a string literal") - void fromClauseDowncastingExample3_SPEC_BUG() { + void fromClauseDowncastingExample3() { assertQuery(""" SELECT e FROM Employee e JOIN e.projects p WHERE TREAT(p AS LargeProject).budget > 1000 - OR TREAT(p AS SmallProject).name LIKE 'Persist%' - OR p.description LIKE "cost overrun" + OR TREAT(p AS SmallProject).name LIKE 'Persist%' + OR p.description LIKE "cost overrun" """); - } - - @Test - void fromClauseDowncastingExample3fixed() { assertQuery(""" SELECT e FROM Employee e JOIN e.projects p WHERE TREAT(p AS LargeProject).budget > 1000 - OR TREAT(p AS SmallProject).name LIKE 'Persist%' - OR p.description LIKE 'cost overrun' + OR TREAT(p AS SmallProject).name LIKE 'Persist%' + OR p.description LIKE 'cost overrun' """); } @@ -511,59 +873,7 @@ void fromClauseDowncastingExample4() { assertQuery(""" SELECT e FROM Employee e WHERE TREAT(e AS Exempt).vacationDays > 10 - OR TREAT(e AS Contractor).hours > 100 - """); - } - - @Test - void pathExpressionsNamedParametersExample() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.status = :stat - """); - } - - @Test - void betweenExpressionsExample() { - - assertQuery(""" - SELECT t - FROM CreditCard c JOIN c.transactionHistory t - WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9 - """); - } - - @Test - void isEmptyExample() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void memberOfExample() { - - assertQuery(""" - SELECT p - FROM Person p - WHERE 'Joe' MEMBER OF p.nicknames - """); - } - - @Test - void existsSubSelectExample1() { - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) + OR TREAT(e AS Contractor).hours > 100 """); } @@ -574,8 +884,8 @@ void allExample() { SELECT emp FROM Employee emp WHERE emp.salary > ALL (SELECT m.salary - FROM Manager m - WHERE m.department = emp.department) + FROM Manager m + WHERE m.department = emp.department) """); } @@ -586,8 +896,8 @@ void existsSubSelectExample2() { SELECT DISTINCT emp FROM Employee emp WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) + FROM Employee spouseEmp + WHERE spouseEmp = emp.spouse) """); } @@ -626,8 +936,7 @@ AND INDEX(w) = 0 * @see #functionInvocationExampleWithCorrection() */ @Test - @Disabled(SPEC_FAULT + "FUNCTION calls needs a comparator") - void functionInvocationExample_SPEC_BUG() { + void functionInvocationExample() { assertQuery(""" SELECT c @@ -646,6 +955,15 @@ WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE """); } + @ParameterizedTest // GH-3628 + @ValueSource(strings = { "is true", "is not true", "is false", "is not false" }) + void functionInvocationWithIsBoolean(String booleanComparison) { + + assertQuery(""" + from RoleTmpl where find_in_set(:appId, appIds) %s + """.formatted(booleanComparison)); + } + @Test void updateCaseExample1() { @@ -653,9 +971,9 @@ void updateCaseExample1() { 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 + WHEN e.rating = 2 THEN e.salary * 1.05 + ELSE e.salary * 1.01 + END """); } @@ -665,10 +983,10 @@ 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 """); } @@ -677,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' """); @@ -692,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) + DELETE + FROM Customer c + WHERE c.status = 'inactive' + AND c.orders IS EMPTY """); - } - - @Test - void theRest2() { assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (:empType1, :empType2) + DELETE + FROM Customer c + WHERE c.status = 'inactive' + AND c.orders IS NOT EMPTY """); } - @Test - void theRest3() { + @Test // GH-3628 + void booleanPredicate() { assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN :empTypes + SELECT c + FROM Customer c + WHERE c.orders IS TRUE """); - } - @Test - void theRest4() { + assertQuery(""" + SELECT c + FROM Customer c + WHERE c.orders IS NOT TRUE + """); assertQuery(""" - SELECT TYPE(e) - FROM Employee e - WHERE TYPE(e) <> Exempt + SELECT c + FROM Customer c + WHERE c.orders IS FALSE """); - } - @Test // GH-2970 - void alternateNotEqualsShouldAlsoWork() { + assertQuery(""" + SELECT c + FROM Customer c + WHERE c.orders IS NOT FALSE + """); assertQuery(""" - SELECT TYPE(e) - FROM Employee e - WHERE TYPE(e) != Exempt + SELECT c + FROM Customer c + WHERE c.orders IS NULL """); assertQuery(""" - SELECT TYPE(e) - FROM Employee e - WHERE TYPE(e) ^= Exempt + SELECT c + FROM Customer c + WHERE c.orders IS NOT NULL """); } + @ParameterizedTest // GH-3628 + @ValueSource(strings = { "IS DISTINCT FROM", "IS NOT DISTINCT FROM" }) + void distinctFromPredicate(String distinctFrom) { + + assertQuery(""" + SELECT c + FROM Customer c + WHERE c.orders %s c.payments + """.formatted(distinctFrom)); + + assertQuery(""" + SELECT c + FROM Customer c + WHERE c.orders %s c.payments + """.formatted(distinctFrom)); + + assertQuery(""" + SELECT c + FROM Customer c + GROUP BY c.lastname + HAVING c.orders %s c.payments + """.formatted(distinctFrom)); + + assertQuery(""" + SELECT c + FROM Customer c + WHERE EXISTS (SELECT c2 + FROM Customer c2 + WHERE c2.orders %s c.orders) + """.formatted(distinctFrom)); + } + @Test void theRest5() { @@ -972,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 """); @@ -1121,85 +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'"); - parseWithoutChanges("select p " + // + assertQuery("select p " + // "from Person p " + // "where p.name like 'Joe''s'"); - parseWithoutChanges("select p " + // + assertQuery("select p " + // "from Person p " + // "where p.id = 1"); - parseWithoutChanges("select p " + // + assertQuery("select p " + // "from Person p " + // "where p.id = 1L"); - parseWithoutChanges("select c " + // + assertQuery("select c " + // "from Call c " + // "where c.duration > 100.5"); - parseWithoutChanges("select c " + // + assertQuery("select c " + // "from Call c " + // "where c.duration > 100.5F"); - parseWithoutChanges("select c " + // + assertQuery("select c " + // "from Call c " + // "where c.duration > 1e+2"); - parseWithoutChanges("select c " + // + assertQuery("select c " + // "from Call c " + // "where c.duration > 1e+2F"); - parseWithoutChanges("from Phone ph " + // + assertQuery("from Phone ph " + // "where ph.type = LAND_LINE"); - parseWithoutChanges("select java.lang.Math.PI"); - parseWithoutChanges("select 'Customer ' || p.name " + // + assertQuery("select java.lang.Math.PI"); + assertQuery("select 'Customer ' || p.name " + // "from Person p " + // "where p.id = 1"); - parseWithoutChanges("select sum(ch.duration) * :multiplier " + // + assertQuery("select sum(ch.duration) * :multiplier " + // "from Person pr " + // "join pr.phones ph " + // "join ph.callHistory ch " + // "where ph.id = 1L "); - parseWithoutChanges("select year(local date) - year(p.createdOn) " + // + assertQuery("select year(local date) - year(p.createdOn) " + // "from Person p " + // "where p.id = 1L"); - parseWithoutChanges("select p " + // + assertQuery("select p " + // "from Person p " + // "where year(local date) - year(p.createdOn) > 1"); - parseWithoutChanges("select " + // + assertQuery("select " + // " case p.nickName " + // " when 'NA' " + // " then '' " + // " else p.nickName " + // " end " + // "from Person p"); - parseWithoutChanges("select " + // + assertQuery("select " + // " case " + // " when p.nickName is null " + // " then " + // @@ -1211,336 +1586,335 @@ 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), " + // @@ -1548,100 +1922,99 @@ void hqlQueries() { " 1" + // ") " + // "from Call c "); - parseWithoutChanges("select new map(" + // - " p.number as phoneNumber , " + // + assertQuery("select new map(" + // + "p.number as phoneNumber, " + // " sum(c.duration) as totalDuration, " + // - " avg(c.duration) as averageDuration " + // + " avg(c.duration) as averageDuration" + // ") " + // "from Call c " + // "join c.phone p " + // "group by p.number "); - parseWithoutChanges("select new list(" + // - " p.number, " + // - " c.duration " + // - ") " + // + assertQuery("select new list(" + // + "p.number, " + // + " c.duration) " + // "from Call c " + // "join c.phone p "); - parseWithoutChanges("select distinct p.lastName " + // + assertQuery("select distinct p.lastName " + // "from Person p"); - parseWithoutChanges("select " + // + assertQuery("select " + // " count(c), " + // " sum(c.duration), " + // " min(c.duration), " + // " max(c.duration), " + // " avg(c.duration) " + // "from Call c "); - parseWithoutChanges("select count(distinct c.phone) " + // + assertQuery("select count(distinct c.phone) " + // "from Call c "); - parseWithoutChanges("select p.number, count(c) " + // + assertQuery("select p.number, count(c) " + // "from Call c " + // "join c.phone p " + // "group by p.number"); - parseWithoutChanges("select p " + // + assertQuery("select p " + // "from Phone p " + // "where max(elements(p.calls)) = :call"); - parseWithoutChanges("select p " + // + assertQuery("select p " + // "from Phone p " + // "where min(elements(p.calls)) = :call"); - parseWithoutChanges("select p " + // + assertQuery("select p " + // "from Person p " + // "where max(indices(p.phones)) = 0"); - parseWithoutChanges("select count(c) filter (where c.duration < 30) " + // + assertQuery("select count(c) filter (where c.duration < 30) " + // "from Call c "); - parseWithoutChanges("select p.number, count(c) filter (where c.duration < 30) " + // + assertQuery("select p.number, count(c) filter (where c.duration < 30) " + // "from Call c " + // "join c.phone p " + // "group by p.number"); - parseWithoutChanges("select listagg(p.number, ', ') within group (order by p.type,p.number) " + // + assertQuery("select listagg(p.number, ', ') within group (order by p.type, p.number) " + // "from Phone p " + // "group by p.person"); - parseWithoutChanges("select sum(c.duration) " + // + assertQuery("select sum(c.duration) " + // "from Call c "); - parseWithoutChanges("select p.name, sum(c.duration) " + // + assertQuery("select p.name, sum(c.duration) " + // "from Call c " + // "join c.phone ph " + // "join ph.person p " + // "group by p.name"); - parseWithoutChanges("select p, sum(c.duration) " + // + assertQuery("select p, sum(c.duration) " + // "from Call c " + // "join c.phone ph " + // "join ph.person p " + // "group by p"); - parseWithoutChanges("select p.name, sum(c.duration) " + // + assertQuery("select p.name, sum(c.duration) " + // "from Call c " + // "join c.phone ph " + // "join ph.person p " + // "group by p.name " + // "having sum(c.duration) > 1000"); - parseWithoutChanges("select p.name from Person p " + // + assertQuery("select p.name from Person p " + // "union " + // "select p.nickName from Person p where p.nickName is not null"); - parseWithoutChanges("select p " + // + assertQuery("select p " + // "from Person p " + // "order by p.name"); - parseWithoutChanges("select p.name, sum(c.duration) as total " + // + assertQuery("select p.name, sum(c.duration) as total " + // "from Call c " + // "join c.phone ph " + // "join ph.person p " + // "group by p.name " + // "order by total"); - parseWithoutChanges("select c " + // + assertQuery("select c " + // "from Call c " + // "join c.phone p " + // "order by p.number " + // "limit 50"); - parseWithoutChanges("select c " + // + assertQuery("select c " + // "from Call c " + // "join c.phone p " + // "order by p.number " + // "fetch first 50 rows only"); - parseWithoutChanges("select c " + // + assertQuery("select c " + // "from Call c " + // "join c.phone p " + // "order by p.number " + // "offset 10 rows " + // "fetch first 50 rows with ties"); - parseWithoutChanges("select p " + // + assertQuery("select p " + // "from Phone p " + // "join fetch p.calls " + // "order by p " + // @@ -1651,29 +2024,25 @@ 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 @@ -1693,14 +2062,12 @@ void shouldSupportLimitOffset() { @Test // GH-2964 void roundFunctionShouldWorkLikeAnyOtherFunction() { - assertThatNoException().isThrownBy(() -> { - parseWithoutChanges(""" - select round(count(ri) * 100 / max(ri.receipt.positions), 0) as perc - from StockOrderItem oi - right join StockReceiptItem ri - on ri.article = oi.article - """); - }); + assertQuery(""" + select round(count(ri) * 100 / max(ri.receipt.positions), 0) as perc + from StockOrderItem oi + right join StockReceiptItem ri + on ri.article = oi.article + """); } @Test // GH-3711 @@ -1713,6 +2080,12 @@ void lnFunctionShouldWork() { assertQuery("select ln(7.5) from Element a"); } + @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() { @@ -1724,6 +2097,31 @@ WITH maxId AS (select max(sr.snapshot.id) snapshotId from SnapshotReference sr """); } + @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() { @@ -1841,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 """); @@ -1883,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() { @@ -1953,6 +2387,18 @@ void entityNameWithPackageContainingReservedWord(String reservedWord) { assertQuery(source); } + @ParameterizedTest // GH-3136 + @ValueSource(strings = { "LEFT", "RIGHT" }) + void leftRightStringFunctions(String keyword) { + assertQuery("SELECT %s(e.name, 3) FROM Employee e".formatted(keyword)); + } + + @Test // GH-3136 + void replaceStringFunctions() { + assertQuery("SELECT REPLACE(e.name, 'o', 'a') FROM Employee e"); + assertQuery("SELECT REPLACE(e.name, ' ', '_') FROM Employee e"); + } + @Test void reservedWordsShouldWork() { @@ -1961,4 +2407,449 @@ void reservedWordsShouldWork() { 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 40a8c4dc7a..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 @@ -22,6 +22,7 @@ import java.util.stream.Stream; import org.assertj.core.api.SoftAssertions; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -32,7 +33,8 @@ import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; import org.springframework.data.jpa.domain.JpaSort; -import org.springframework.lang.Nullable; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.query.ReturnedType; import org.springframework.util.StringUtils; /** @@ -107,6 +109,19 @@ 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() { @@ -137,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() { @@ -184,9 +205,9 @@ void multipleAliasesShouldBeGathered() { @Test void createsCountQueryCorrectly() { - assertCountQuery("SELECT id FROM Person", "SELECT count(id) FROM Person"); + 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(id) 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); @@ -230,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 ", @@ -247,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"); @@ -267,6 +296,11 @@ void detectsAliasCorrectly() { assertThat(alias( "SELECT e FROM DbEvent e WHERE TREAT(modifiedFrom AS date) IS NULL OR e.modificationDate >= :modifiedFrom")) .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 @@ -280,13 +314,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 @@ -830,6 +866,38 @@ void countQueryUsesCorrectVariable() { .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 void orderByShouldWorkWithSubSelectStatements() { @@ -1114,20 +1182,30 @@ void aliasesShouldNotOverlapWithSortProperties() { "SELECT t3 FROM Test3 t3 JOIN t3.test2 x WHERE x.id = :test2Id order by t3.testDuplicateColumnName desc"); } - @Test // GH-3269, GH-3689 - void createsCountQueryUsingAliasCorrectly() { + @Test // GH-3864 + void testCountFromFunctionWithAlias() { - 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"); + // 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 @@ -1172,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) { @@ -1183,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(); } @@ -1197,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 be05e3fceb..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/HqlSpecificationTests.java +++ /dev/null @@ -1,1788 +0,0 @@ -/* - * Copyright 2022-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.query; - -import static org.assertj.core.api.Assertions.*; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; - -import org.springframework.data.jpa.repository.query.QueryRenderer.TokenRenderer; - -/** - * Tests built around examples of HQL found in - * https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc and - * https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html#query-language
      - *
      - * IMPORTANT: Purely verifies the parser without any transformations. - * - * @author Greg Turnquist - * @author Mark Paluch - * @author Christoph Strobl - * @since 3.1 - */ -class HqlSpecificationTests { - - private static final String SPEC_FAULT = "Disabled due to spec fault> "; - - private static String parseWithoutChanges(String query) { - - JpaQueryEnhancer.HqlQueryParser parser = JpaQueryEnhancer.HqlQueryParser.parseQuery(query); - - return TokenRenderer.render(new HqlQueryRenderer().visit(parser.getContext())); - } - - private void assertQuery(String query) { - - String slimmedDownQuery = reduceWhitespace(query); - assertThat(parseWithoutChanges(slimmedDownQuery)).isEqualTo(slimmedDownQuery); - } - - private String reduceWhitespace(String original) { - - return original // - .replaceAll("[ \\t\\n]{1,}", " ") // - .trim(); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example - */ - @Test - void joinExample1() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order AS o JOIN o.lineItems AS l - WHERE l.shipped = FALSE - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#identification-variables - */ - @Test - void joinExample2() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l JOIN l.product p - WHERE p.productType = 'office_supplies' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#range-variable-declarations - */ - @Test - void rangeVariableDeclarations() { - - assertQuery(""" - SELECT DISTINCT o1 - FROM Order o1, Order o2 - WHERE o1.quantity > o2.quantity AND - o2.customer.lastname = 'Smith' AND - o2.customer.firstname = 'John' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample1() { - - assertQuery(""" - SELECT i.name, VALUE(p) - FROM Item i JOIN i.photos p - WHERE KEY(p) LIKE '%egret' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample2() { - - assertQuery(""" - SELECT i.name, p - FROM Item i JOIN i.photos p - WHERE KEY(p) LIKE '%egret' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample3() { - - assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo.phones p - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample4() { - - assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE e.contactInfo.address.zipcode = '95054' - """); - } - - @Test - void pathExpressionSyntaxExample1() { - - assertQuery(""" - SELECT DISTINCT l.product - FROM Order AS o JOIN o.lineItems l - """); - } - - @Test - void joinsExample1() { - - assertQuery(""" - SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize - """); - } - - @Test - void joinsExample2() { - - assertQuery(""" - SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInnerExample() { - - assertQuery(""" - SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInExample() { - - assertQuery(""" - SELECT OBJECT(c) FROM Customer c , IN(c.orders) o WHERE c.status = 1 - """); - } - - @Test - void doubleJoinExample() { - - assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE c.address.zipcode = '95054' - """); - } - - @Test - void leftJoinExample() { - - assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - GROUP BY s.name - """); - } - - @Test - void leftJoinOnExample() { - - assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - ON p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinWhereExample() { - - assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - WHERE p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinFetchExample() { - - assertQuery(""" - SELECT d - FROM Department d LEFT JOIN FETCH d.employees - WHERE d.deptno = 1 - """); - } - - @Test - void collectionMemberExample() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void collectionMemberInExample() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o , IN(o.lineItems) l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void fromClauseExample() { - - assertQuery(""" - SELECT o - FROM Order AS o JOIN o.lineItems l JOIN l.product p - """); - } - - @Test - void fromClauseDowncastingExample1() { - - assertQuery(""" - SELECT b.name, b.ISBN - FROM Order o JOIN TREAT(o.product AS Book) b - """); - } - - @Test - void fromClauseDowncastingExample2() { - - assertQuery(""" - SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp - WHERE lp.budget > 1000 - """); - } - - /** - * @see #fromClauseDowncastingExample3fixed() - */ - @Test - @Disabled(SPEC_FAULT + "Use double-quotes when it should be using single-quotes for a string literal") - void fromClauseDowncastingExample3_SPEC_BUG() { - - assertQuery(""" - SELECT e FROM Employee e JOIN e.projects p - WHERE TREAT(p AS LargeProject).budget > 1000 - OR TREAT(p AS SmallProject).name LIKE 'Persist%' - OR p.description LIKE "cost overrun" - """); - } - - @Test - void fromClauseDowncastingExample3fixed() { - - assertQuery(""" - SELECT e FROM Employee e JOIN e.projects p - WHERE TREAT(p AS LargeProject).budget > 1000 - OR TREAT(p AS SmallProject).name LIKE 'Persist%' - OR p.description LIKE 'cost overrun' - """); - } - - @Test - void fromClauseDowncastingExample4() { - - assertQuery(""" - SELECT e FROM Employee e - WHERE TREAT(e AS Exempt).vacationDays > 10 - OR TREAT(e AS Contractor).hours > 100 - """); - } - - @ParameterizedTest // GH-3689 - @ValueSource(strings = { "RESPECT NULLS", "IGNORE NULLS" }) - void generic(String nullHandling) { - - // not in the official documentation but supported in the grammar. - assertQuery(""" - SELECT e FROM Employee e - WHERE FOO(x).bar %s - """.formatted(nullHandling)); - } - - @Test // GH-3689 - void size() { - - assertQuery(""" - SELECT e FROM Employee e - WHERE SIZE(x) > 1 - """); - - assertQuery(""" - SELECT e FROM Employee e - WHERE SIZE(e.skills) > 1 - """); - } - - @Test // GH-3689 - void collectionAggregate() { - - assertQuery(""" - SELECT e FROM Employee e - WHERE MAXELEMENT(foo) > MINELEMENT(bar) - """); - - assertQuery(""" - SELECT e FROM Employee e - WHERE MININDEX(foo) > MAXINDEX(bar) - """); - } - - @Test // GH-3689 - void trunc() { - - assertQuery(""" - SELECT e FROM Employee e - WHERE TRUNC(x) = TRUNCATE(y) - """); - - assertQuery(""" - SELECT e FROM Employee e - WHERE TRUNC(e, 'foo') = TRUNCATE(e, 'bar') - """); - - assertQuery(""" - SELECT e FROM Employee e - WHERE TRUNC(e, 'YEAR') = TRUNCATE(LOCAL DATETIME, 'YEAR') - """); - } - - @ParameterizedTest // GH-3689 - @ValueSource(strings = { "YEAR", "MONTH", "DAY", "WEEK", "QUARTER", "HOUR", "MINUTE", "SECOND", "NANOSECOND", - "NANOSECOND", "EPOCH" }) - void trunc(String truncation) { - - assertQuery(""" - SELECT e FROM Employee e - WHERE TRUNC(e, %1$s) = TRUNCATE(e, %1$s) - """.formatted(truncation)); - } - - @Test // GH-3689 - void format() { - - assertQuery(""" - SELECT e FROM Employee e - WHERE FORMAT(x AS 'yyyy') = FORMAT(e.hiringDate AS 'yyyy') - """); - - assertQuery(""" - SELECT e FROM Employee e - WHERE e.hiringDate = format(LOCAL DATETIME as 'yyyy-MM-dd') - """); - - assertQuery(""" - SELECT e FROM Employee e - WHERE e.hiringDate = format(LOCAL_DATE() as 'yyyy-MM-dd') - """); - } - - @Test // GH-3689 - void collate() { - - assertQuery(""" - SELECT e FROM Employee e - WHERE COLLATE(x AS ucs_basic) = COLLATE(e.name AS ucs_basic) - """); - } - - @Test // GH-3689 - void substring() { - - assertQuery("select substring(c.number, 1, 2) " + // - "from Call c"); - - assertQuery("select substring(c.number, 1) " + // - "from Call c"); - - assertQuery("select substring(c.number, 1, position('/0' in c.number)) " + // - "from Call c"); - - assertQuery("select substring(c.number FROM 1 FOR 2) " + // - "from Call c"); - - assertQuery("select substring(c.number FROM 1) " + // - "from Call c"); - - assertQuery("select substring(c.number FROM 1 FOR position('/0' in c.number)) " + // - "from Call c"); - - assertQuery("select substring(c.number FROM 1) AS shortNumber " + // - "from Call c"); - } - - @Test // GH-3689 - void overlay() { - - assertQuery("select OVERLAY(c.number PLACING 1 FROM 2) " + // - "from Call c "); - - assertQuery("select OVERLAY(p.number PLACING 1 FROM 2 FOR 3) " + // - "from Call c "); - } - - @Test // GH-3689 - void pad() { - - assertQuery("select PAD(c.number WITH 1 LEADING) " + // - "from Call c "); - - assertQuery("select PAD(c.number WITH 1 TRAILING) " + // - "from Call c "); - - assertQuery("select PAD(c.number WITH 1 LEADING '0') " + // - "from Call c "); - - assertQuery("select PAD(c.number WITH 1 TRAILING '0') " + // - "from Call c "); - } - - @Test // GH-3689 - void position() { - - assertQuery("select POSITION(c.number IN 'foo') " + // - "from Call c "); - - assertQuery("select POSITION(c.number IN 'foo') + 1 AS pos " + // - "from Call c "); - } - - @Test // GH-3689 - void currentDateFunctions() { - - assertQuery("select CURRENT DATE, CURRENT_DATE() " + // - "from Call c "); - - assertQuery("select CURRENT TIME, CURRENT_TIME() " + // - "from Call c "); - - assertQuery("select CURRENT TIMESTAMP, CURRENT_TIMESTAMP() " + // - "from Call c "); - - assertQuery("select INSTANT, CURRENT_INSTANT() " + // - "from Call c "); - - assertQuery("select LOCAL DATE, LOCAL_DATE() " + // - "from Call c "); - - assertQuery("select LOCAL TIME, LOCAL_TIME() " + // - "from Call c "); - - assertQuery("select LOCAL DATETIME, LOCAL_DATETIME() " + // - "from Call c "); - - assertQuery("select OFFSET DATETIME, OFFSET_DATETIME() " + // - "from Call c "); - - assertQuery("select OFFSET DATETIME AS offsetDatetime, OFFSET_DATETIME() AS offset_datetime " + // - "from Call c "); - } - - @Test // GH-3689 - void cube() { - - assertQuery("select CUBE(foo), CUBE(foo, bar) " + // - "from Call c "); - - assertQuery("select c.callerId from Call c GROUP BY CUBE(state, province)"); - } - - @Test // GH-3689 - void rollup() { - - assertQuery("select ROLLUP(foo), ROLLUP(foo, bar) " + // - "from Call c "); - - assertQuery("select c.callerId from Call c GROUP BY ROLLUP(state, province)"); - } - - @Test - void pathExpressionsNamedParametersExample() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.status = :stat - """); - } - - @Test - void betweenExpressionsExample() { - - assertQuery(""" - SELECT t - FROM CreditCard c JOIN c.transactionHistory t - WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9 - """); - } - - @Test - void isEmptyExample() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void memberOfExample() { - - assertQuery(""" - SELECT p - FROM Person p - WHERE 'Joe' MEMBER OF p.nicknames - """); - } - - @Test - void existsSubSelectExample1() { - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test // GH-3689 - void everyAll() { - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EVERY (SELECT spouseEmp - FROM Employee spouseEmp) > 1 - """); - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE ALL (SELECT spouseEmp - FROM Employee spouseEmp) > 1 - """); - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE ALL (foo > 1) OVER (PARTITION BY bar) - """); - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE ALL VALUES (foo) > 1 - """); - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE ALL ELEMENTS (foo) > 1 - """); - } - - @Test // GH-3689 - void anySome() { - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE ANY (SELECT spouseEmp - FROM Employee spouseEmp) > 1 - """); - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE SOME (SELECT spouseEmp - FROM Employee spouseEmp) > 1 - """); - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE ANY (foo > 1) OVER (PARTITION BY bar) - """); - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE ANY VALUES (foo) > 1 - """); - } - - @Test // GH-3689 - void listAgg() { - - assertQuery("select listagg(p.number, ', ') within group (order by p.type, p.number) " + // - "from Phone p " + // - "group by p.person"); - } - - @Test - void allExample() { - - assertQuery(""" - SELECT emp - FROM Employee emp - WHERE emp.salary > ALL (SELECT m.salary - FROM Manager m - WHERE m.department = emp.department) - """); - } - - @Test - void existsSubSelectExample2() { - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void subselectNumericComparisonExample1() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE (SELECT AVG(o.price) FROM c.orders o) > 100 - """); - } - - @Test - void subselectNumericComparisonExample2() { - - assertQuery(""" - SELECT goodCustomer - FROM Customer goodCustomer - WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed) / 2.0 FROM Customer c) - """); - } - - @Test - void indexExample() { - - assertQuery(""" - SELECT w.name - FROM Course c JOIN c.studentWaitlist w - WHERE c.name = 'Calculus' - AND INDEX(w) = 0 - """); - } - - /** - * @see #functionInvocationExampleWithCorrection() - */ - @Test - void functionInvocationExampleAsBooleanExpression() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) - """); - } - - @Test - void functionInvocationExampleWithCorrection() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE - """); - } - - @ParameterizedTest // GH-3628 - @ValueSource(strings = { "is true", "is not true", "is false", "is not false" }) - void functionInvocationWithIsBoolean(String booleanComparison) { - - assertQuery(""" - from RoleTmpl where find_in_set(:appId, appIds) %s - """.formatted(booleanComparison)); - } - - @Test - void updateCaseExample1() { - - assertQuery(""" - UPDATE Employee e - SET e.salary = - CASE WHEN e.rating = 1 THEN e.salary * 1.1 - WHEN e.rating = 2 THEN e.salary * 1.05 - ELSE e.salary * 1.01 - END - """); - } - - @Test - void updateCaseExample2() { - - assertQuery(""" - UPDATE Employee e - SET e.salary = - CASE e.rating WHEN 1 THEN e.salary * 1.1 - WHEN 2 THEN e.salary * 1.05 - ELSE e.salary * 1.01 - END - """); - } - - @Test - void selectCaseExample1() { - - assertQuery(""" - SELECT e.name, - CASE TYPE(e) WHEN Exempt THEN 'Exempt' - WHEN Contractor THEN 'Contractor' - WHEN Intern THEN 'Intern' - ELSE 'NonExempt' - END - FROM Employee e - WHERE e.dept.name = 'Engineering' - """); - } - - @Test - void selectCaseExample2() { - - assertQuery(""" - SELECT e.name, - f.name, - CONCAT(CASE WHEN f.annualMiles > 50000 THEN 'Platinum ' - WHEN f.annualMiles > 25000 THEN 'Gold ' - ELSE '' - END, - 'Frequent Flyer') - FROM Employee e JOIN e.frequentFlierPlan f - """); - } - - @Test - void theRest() { - - assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (Exempt, Contractor) - """); - } - - @Test - void theRest2() { - - assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (:empType1, :empType2) - """); - } - - @Test - void theRest3() { - - assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN :empTypes - """); - } - - @Test - void theRest4() { - - assertQuery(""" - SELECT TYPE(e) - FROM Employee e - WHERE TYPE(e) <> Exempt - """); - } - - @Test - void theRest5() { - - assertQuery(""" - SELECT c.status, AVG(c.filledOrderCount), COUNT(c) - FROM Customer c - GROUP BY c.status - HAVING c.status IN (1, 2) - """); - } - - @Test - void theRest6() { - - assertQuery(""" - SELECT c.country, COUNT(c) - FROM Customer c - GROUP BY c.country - HAVING COUNT(c) > 30 - """); - } - - @Test - void theRest7() { - - assertQuery(""" - SELECT c, COUNT(o) - FROM Customer c JOIN c.orders o - GROUP BY c - HAVING COUNT(o) >= 5 - """); - } - - @Test - void theRest8() { - - assertQuery(""" - SELECT c.id, c.status - FROM Customer c JOIN c.orders o - WHERE o.count > 100 - """); - } - - @Test - void theRest9() { - - assertQuery(""" - SELECT v.location.street, KEY(i).title, VALUE(i) - FROM VideoStore v JOIN v.videoInventory i - WHERE v.location.zipcode = '94301' AND VALUE(i) > 0 - """); - } - - @Test - void theRest10() { - - assertQuery(""" - SELECT o.lineItems FROM Order AS o - """); - } - - @Test - void theRest11() { - - assertQuery(""" - SELECT c, COUNT(l) AS itemCount - FROM Customer c JOIN c.Orders o JOIN o.lineItems l - WHERE c.address.state = 'CA' - GROUP BY c - ORDER BY itemCount - """); - } - - @Test - void theRest12() { - - assertQuery(""" - SELECT NEW com.acme.example.CustomerDetails(c.id, c.status, o.count) - FROM Customer c JOIN c.orders o - WHERE o.count > 100 - """); - } - - @Test - void theRest13() { - - assertQuery(""" - SELECT e.address AS addr - FROM Employee e - """); - } - - @Test - void theRest14() { - - assertQuery(""" - SELECT AVG(o.quantity) FROM Order o - """); - } - - @Test - void theRest15() { - - assertQuery(""" - SELECT SUM(l.price) - FROM Order o JOIN o.lineItems l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - """); - } - - @Test - void theRest16() { - - assertQuery(""" - SELECT COUNT(o) FROM Order o - """); - } - - @Test - void theRest17() { - - assertQuery(""" - SELECT COUNT(l.price) - FROM Order o JOIN o.lineItems l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - """); - } - - @Test - void theRest18() { - - assertQuery(""" - SELECT COUNT(l) - FROM Order o JOIN o.lineItems l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' AND l.price IS NOT NULL - """); - } - - @Test - void theRest19() { - - assertQuery(""" - SELECT o - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' - ORDER BY o.quantity DESC, o.totalcost - """); - } - - @Test - void theRest20() { - - assertQuery(""" - SELECT o.quantity, a.zipcode - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' - ORDER BY o.quantity, a.zipcode - """); - } - - @Test - void theRest21() { - - assertQuery(""" - SELECT o.quantity, o.cost * 1.08 AS taxedCost, a.zipcode - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' AND a.county = 'Santa Clara' - ORDER BY o.quantity, taxedCost, a.zipcode - """); - } - - @Test - void theRest22() { - - assertQuery(""" - SELECT AVG(o.quantity) as q, a.zipcode - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' - GROUP BY a.zipcode - ORDER BY q DESC - """); - } - - @Test - void theRest23() { - - assertQuery(""" - SELECT p.product_name - FROM Order o JOIN o.lineItems l JOIN l.product p JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - ORDER BY p.price - """); - } - - /** - * This query is specifically dubbed illegal in the spec, but apparently works with Hibernate. - */ - @Test - void theRest24() { - - assertQuery(""" - SELECT p.product_name - FROM Order o , IN(o.lineItems) l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - ORDER BY o.quantity - """); - } - - @Test - void theRest25() { - - assertQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - """); - } - - @Test - void collectionIsEmpty() { - - assertQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - AND c.orders IS EMPTY - """); - - assertQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - AND c.orders IS NOT EMPTY - """); - } - - @Test // GH-3628 - void booleanPredicate() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.orders IS TRUE - """); - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.orders IS NOT TRUE - """); - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.orders IS FALSE - """); - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.orders IS NOT FALSE - """); - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.orders IS NULL - """); - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.orders IS NOT NULL - """); - } - - @ParameterizedTest // GH-3628 - @ValueSource(strings = { "IS DISTINCT FROM", "IS NOT DISTINCT FROM" }) - void distinctFromPredicate(String distinctFrom) { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.orders %s c.payments - """.formatted(distinctFrom)); - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.orders %s c.payments - """.formatted(distinctFrom)); - - assertQuery(""" - SELECT c - FROM Customer c - GROUP BY c.lastname - HAVING c.orders %s c.payments - """.formatted(distinctFrom)); - - assertQuery(""" - SELECT c - FROM Customer c - WHERE EXISTS (SELECT c2 - FROM Customer c2 - WHERE c2.orders %s c.orders) - """.formatted(distinctFrom)); - } - - @Test - void theRest27() { - - assertQuery(""" - UPDATE Customer c - SET c.status = 'outstanding' - WHERE c.balance < 10000 - """); - } - - @Test - void theRest28() { - - assertQuery(""" - UPDATE Employee e - SET e.address.building = 22 - WHERE e.address.building = 14 - AND e.address.city = 'Santa Clara' - AND e.project = 'Jakarta EE' - """); - } - - @Test - void theRest29() { - - assertQuery(""" - SELECT o - FROM Order o - """); - } - - @Test - void theRest30() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress.state = 'CA' - """); - } - - @Test - void theRest31() { - - assertQuery(""" - SELECT DISTINCT o.shippingAddress.state - FROM Order o - """); - } - - @Test - void theRest32() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - """); - } - - @Test - void theRest33() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS NOT EMPTY - """); - } - - @Test - void theRest34() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void theRest35() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.shipped = FALSE - """); - } - - @Test - void theRest36() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE - NOT (o.shippingAddress.state = o.billingAddress.state AND - o.shippingAddress.city = o.billingAddress.city AND - o.shippingAddress.street = o.billingAddress.street) - """); - } - - @Test - void theRest37() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress <> o.billingAddress - """); - } - - @Test - void theRest38() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.name = ?1 - """); - } - - @Test // GH-3689 - void insertQueries() { - - assertQuery("insert Person (id, name) values (100L, 'Jane Doe')"); - - assertQuery("insert Person (id, name) values " + // - "(101L, 'J A Doe III'), " + // - "(102L, 'J X Doe'), " + // - "(103L, 'John Doe, Jr')"); - - assertQuery("insert into Partner (id, name) " + // - "select p.id, p.name from Person p "); - - assertQuery("INSERT INTO AggregationPrice (range, price, type) " + "VALUES (:range, :price, :priceType) " - + "ON CONFLICT (range) DO UPDATE SET price = :price, type = :priceType"); - - assertQuery("INSERT INTO AggregationPrice (range, price, type) " + "VALUES (:range, :price, :priceType) " - + "ON CONFLICT ON CONSTRAINT foo DO UPDATE SET price = :price, type = :priceType"); - - assertQuery("INSERT INTO AggregationPrice (range, price, type) " + "VALUES (:range, :price, :priceType) " - + "ON CONFLICT ON CONSTRAINT foo DO NOTHING"); - } - - @Test - void hqlQueries() { - - assertQuery("from Person"); - assertQuery("select local datetime"); - assertQuery("from Person p select p.name"); - assertQuery("update Person set nickName = 'Nacho' " + // - "where name = 'Ignacio'"); - assertQuery("update Person p " + // - "set p.name = :newName " + // - "where p.name = :oldName"); - assertQuery("update Person " + // - "set name = :newName " + // - "where name = :oldName"); - assertQuery("update versioned Person " + // - "set name = :newName " + // - "where name = :oldName"); - - assertQuery("select p " + // - "from Person p " + // - "where p.name like 'Joe'"); - assertQuery("select p " + // - "from Person p " + // - "where p.name like 'Joe''s'"); - assertQuery("select p " + // - "from Person p " + // - "where p.id = 1"); - assertQuery("select p " + // - "from Person p " + // - "where p.id = 1L"); - assertQuery("select c " + // - "from Call c " + // - "where c.duration > 100.5"); - assertQuery("select c " + // - "from Call c " + // - "where c.duration > 100.5F"); - assertQuery("select c " + // - "from Call c " + // - "where c.duration > 1e+2"); - assertQuery("select c " + // - "from Call c " + // - "where c.duration > 1e+2F"); - assertQuery("from Phone ph " + // - "where ph.type = LAND_LINE"); - assertQuery("select java.lang.Math.PI"); - assertQuery("select 'Customer ' || p.name " + // - "from Person p " + // - "where p.id = 1"); - assertQuery("select sum(ch.duration) * :multiplier " + // - "from Person pr " + // - "join pr.phones ph " + // - "join ph.callHistory ch " + // - "where ph.id = 1L "); - assertQuery("select year(local date) - year(p.createdOn) " + // - "from Person p " + // - "where p.id = 1L"); - assertQuery("select p " + // - "from Person p " + // - "where year(local date) - year(p.createdOn) > 1"); - assertQuery("select " + // - " case p.nickName " + // - " when 'NA' " + // - " then '' " + // - " else p.nickName " + // - " end " + // - "from Person p"); - assertQuery("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"); - assertQuery("select " + // - " case when p.nickName is null " + // - " then p.id * 1000 " + // - " else p.id " + // - " end " + // - "from Person p " + // - "order by p.id"); - assertQuery("select p " + // - "from Payment p " + // - "where type(p) = CreditCardPayment"); - assertQuery("select p " + // - "from Payment p " + // - "where type(p) = :type"); - assertQuery("select p " + // - "from Payment p " + // - "where length(treat(p as CreditCardPayment).cardNumber) between 16 and 20"); - assertQuery("select nullif(p.nickName, p.name) " + // - "from Person p"); - assertQuery("select " + // - " case" + // - " when p.nickName = p.name" + // - " then null" + // - " else p.nickName" + // - " end " + // - "from Person p"); - assertQuery("select coalesce(p.nickName, '') " + // - "from Person p"); - assertQuery("select coalesce(p.nickName, p.name, '') " + // - "from Person p"); - assertQuery("select p " + // - "from Person p " + // - "where size(p.phones) >= 2"); - assertQuery("select concat(p.number, ' : ', cast(c.duration as string)) " + // - "from Call c " + // - "join c.phone p"); - assertQuery("select upper(p.name) " + // - "from Person p "); - assertQuery("select lower(p.name) " + // - "from Person p "); - assertQuery("select trim(p.name) " + // - "from Person p "); - assertQuery("select trim(leading ' ' from p.name) " + // - "from Person p "); - assertQuery("select length(p.name) " + // - "from Person p "); - assertQuery("select locate('John', p.name) " + // - "from Person p "); - assertQuery("select abs(c.duration) " + // - "from Call c "); - assertQuery("select mod(c.duration, 10) " + // - "from Call c "); - assertQuery("select sqrt(c.duration) " + // - "from Call c "); - assertQuery("select cast(c.duration as String) " + // - "from Call c "); - assertQuery("select str(c.timestamp) " + // - "from Call c "); - assertQuery("select str(cast(duration as float) / 60, 4, 2) " + // - "from Call c "); - assertQuery("select c " + // - "from Call c " + // - "where extract(date from c.timestamp) = local date"); - assertQuery("select extract(year from c.timestamp) " + // - "from Call c "); - assertQuery("select year(c.timestamp) " + // - "from Call c "); - assertQuery("select var_samp(c.duration) as sampvar, var_pop(c.duration) as popvar " + // - "from Call c "); - assertQuery("select bit_length(c.phone.number) " + // - "from Call c "); - assertQuery("select c " + // - "from Call c " + // - "where c.duration < 30 "); - assertQuery("select p " + // - "from Person p " + // - "where p.name like 'John%' "); - assertQuery("select p " + // - "from Person p " + // - "where p.createdOn > '1950-01-01' "); - assertQuery("select p " + // - "from Phone p " + // - "where p.type = 'MOBILE' "); - assertQuery("select p " + // - "from Payment p " + // - "where p.completed = true "); - assertQuery("select p " + // - "from Payment p " + // - "where type(p) = WireTransferPayment "); - assertQuery("select p " + // - "from Payment p, Phone ph " + // - "where p.person = ph.person "); - assertQuery("select p " + // - "from Person p " + // - "join p.phones ph " + // - "where p.id = 1L and index(ph) between 0 and 3"); - assertQuery("select p " + // - "from Person p " + // - "where p.createdOn between '1999-01-01' and '2001-01-02'"); - assertQuery("select c " + // - "from Call c " + // - "where c.duration between 5 and 20"); - assertQuery("select p " + // - "from Person p " + // - "where p.name between 'H' and 'M'"); - assertQuery("select p " + // - "from Person p " + // - "where p.nickName is not null"); - assertQuery("select p " + // - "from Person p " + // - "where p.nickName is null"); - assertQuery("select p " + // - "from Person p " + // - "where p.name like 'Jo%'"); - assertQuery("select p " + // - "from Person p " + // - "where p.name not like 'Jo%'"); - assertQuery("select p " + // - "from Person p " + // - "where p.name like 'Dr|_%' escape '|'"); - assertQuery("select p " + // - "from Payment p " + // - "where type(p) in (CreditCardPayment, WireTransferPayment)"); - assertQuery("select p " + // - "from Phone p " + // - "where type in ('MOBILE', 'LAND_LINE')"); - assertQuery("select p " + // - "from Phone p " + // - "where type in :types"); - assertQuery("select distinct p " + // - "from Phone p " + // - "where p.person.id in (select py.person.id " + // - " from Payment py" + // - " where py.completed = true and py.amount > 50)"); - assertQuery("select distinct p " + // - "from Phone p " + // - "where p.person in (select py.person " + // - " from Payment py" + // - " where py.completed = true and py.amount > 50)"); - assertQuery("select distinct p " + // - "from Payment p " + // - "where (p.amount, p.completed) in ((50, true)," + // - " (100, true)," + // - " (5, false))"); - assertQuery("select p " + // - "from Person p " + // - "where 1 in indices (p.phones)"); - assertQuery("select distinct p.person " + // - "from Phone p " + // - "join p.calls c " + // - "where 50 > all (select duration" + // - " from Call" + // - " where phone = p) "); - assertQuery("select p " + // - "from Phone p " + // - "where local date > all elements (p.repairTimestamps)"); - assertQuery("select p " + // - "from Person p " + // - "where :phone = some elements (p.phones)"); - assertQuery("select p " + // - "from Person p " + // - "where :phone member of p.phones"); - assertQuery("select p " + // - "from Person p " + // - "where exists elements (p.phones)"); - assertQuery("select p " + // - "from Person p " + // - "where p.phones is empty"); - assertQuery("select p " + // - "from Person p " + // - "where p.phones is not empty"); - assertQuery("select p " + // - "from Person p " + // - "where p.phones is not empty"); - assertQuery("select p " + // - "from Person p " + // - "where 'Home address' member of p.addresses"); - assertQuery("select p " + // - "from Person p " + // - "where 'Home address' not member of p.addresses"); - assertQuery("select p " + // - "from Person p"); - assertQuery("select p " + // - "from org.hibernate.userguide.model.Person p"); - assertQuery("select distinct pr, ph " + // - "from Person pr, Phone ph " + // - "where ph.person = pr and ph is not null"); - assertQuery("select distinct pr1 " + // - "from Person pr1, Person pr2 " + // - "where pr1.id <> pr2.id " + // - " and pr1.address = pr2.address " + // - " and pr1.createdOn < pr2.createdOn"); - assertQuery("select distinct pr, ph " + // - "from Person pr cross join Phone ph " + // - "where ph.person = pr and ph is not null"); - assertQuery("select p " + // - "from Payment p "); - assertQuery("select d.owner, d.payed " + // - "from (select p.person as owner, c.payment is not null as payed " + // - " from Call c " + // - " join c.phone p " + // - " where p.number = :phoneNumber) d"); - assertQuery("select distinct pr " + // - "from Person pr " + // - "join Phone ph on ph.person = pr " + // - "where ph.type = :phoneType"); - assertQuery("select distinct pr " + // - "from Person pr " + // - "join pr.phones ph " + // - "where ph.type = :phoneType"); - assertQuery("select distinct pr " + // - "from Person pr " + // - "inner join pr.phones ph " + // - "where ph.type = :phoneType"); - assertQuery("select distinct pr " + // - "from Person pr " + // - "left join pr.phones ph " + // - "where ph is null " + // - " or ph.type = :phoneType"); - assertQuery("select distinct pr " + // - "from Person pr " + // - "left outer join pr.phones ph " + // - "where ph is null " + // - " or ph.type = :phoneType"); - assertQuery("select pr.name, ph.number " + // - "from Person pr " + // - "left join pr.phones ph with ph.type = :phoneType "); - assertQuery("select pr.name, ph.number " + // - "from Person pr " + // - "left join pr.phones ph on ph.type = :phoneType "); - assertQuery("select distinct pr " + // - "from Person pr " + // - "left join fetch pr.phones "); - assertQuery("select a, ccp " + // - "from Account a " + // - "join treat(a.payments as CreditCardPayment) ccp " + // - "where length(ccp.cardNumber) between 16 and 20"); - assertQuery("select c, ccp " + // - "from Call c " + // - "join treat(c.payment as CreditCardPayment) ccp " + // - "where length(ccp.cardNumber) between 16 and 20"); - assertQuery("select longest.duration " + // - "from Phone p " + // - "left join lateral (" + // - "select c.duration as duration " + // - " from p.calls c" + // - " order by c.duration desc" + // - " limit 1 " + // - " ) longest " + // - "where p.number = :phoneNumber"); - assertQuery("select ph " + // - "from Phone ph " + // - "where ph.person.address = :address "); - assertQuery("select ph " + // - "from Phone ph " + // - "join ph.person pr " + // - "where pr.address = :address "); - assertQuery("select ph " + // - "from Phone ph " + // - "where ph.person.address = :address " + // - " and ph.person.createdOn > :timestamp"); - assertQuery("select ph " + // - "from Phone ph " + // - "inner join ph.person pr " + // - "where pr.address = :address " + // - " and pr.createdOn > :timestamp"); - assertQuery("select ph " + // - "from Person pr " + // - "join pr.phones ph " + // - "join ph.calls c " + // - "where pr.address = :address " + // - " and c.duration > :duration"); - assertQuery("select ch " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - assertQuery("select value(ch) " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - assertQuery("select key(ch) " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - assertQuery("select key(ch) " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - assertQuery("select entry (ch) " + // - "from Phone ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id "); - assertQuery("select sum(ch.duration) " + // - "from Person pr " + // - "join pr.phones ph " + // - "join ph.callHistory ch " + // - "where ph.id = :id " + // - " and index(ph) = :phoneIndex"); - assertQuery("select value(ph.callHistory) " + // - "from Phone ph " + // - "where ph.id = :id "); - assertQuery("select key(ph.callHistory) " + // - "from Phone ph " + // - "where ph.id = :id "); - assertQuery("select p " + // - "from Person p " + // - "where p.phones[0].type = LAND_LINE"); - assertQuery("select p " + // - "from Person p " + // - "where p.addresses['HOME'] = :address"); - assertQuery("select pr " + // - "from Person pr " + // - "where pr.phones[max(indices(pr.phones))].type = 'LAND_LINE'"); - assertQuery("select p.name, p.nickName " + // - "from Person p "); - assertQuery("select p.name as name, p.nickName as nickName " + // - "from Person p "); - assertQuery("select new org.hibernate.userguide.hql.CallStatistics(count(c), " + // - " sum(c.duration), " + // - " min(c.duration), " + // - " max(c.duration), " + // - " avg(c.duration)" + // - ") " + // - "from Call c "); - assertQuery("select new map(p.number as phoneNumber, " + // - " sum(c.duration) as totalDuration, " + // - " avg(c.duration) as averageDuration) " + // - "from Call c " + // - "join c.phone p " + // - "group by p.number "); - assertQuery("select new list(p.number," + // - " c.duration) " + // - "from Call c " + // - "join c.phone p "); - assertQuery("select distinct p.lastName " + // - "from Person p"); - assertQuery("select " + // - " count(c), " + // - " sum(c.duration), " + // - " min(c.duration), " + // - " max(c.duration), " + // - " avg(c.duration) " + // - "from Call c "); - assertQuery("select count(distinct c.phone) " + // - "from Call c "); - assertQuery("select p.number, count(c) " + // - "from Call c " + // - "join c.phone p " + // - "group by p.number"); - assertQuery("select p " + // - "from Phone p " + // - "where max(elements(p.calls)) = :call"); - assertQuery("select p " + // - "from Phone p " + // - "where min(elements(p.calls)) = :call"); - assertQuery("select p " + // - "from Person p " + // - "where max(indices(p.phones)) = 0"); - assertQuery("select count(c) filter (where c.duration < 30) " + // - "from Call c "); - assertQuery("select p.number, count(c) filter (where c.duration < 30) " + // - "from Call c " + // - "join c.phone p " + // - "group by p.number"); - assertQuery("select sum(c.duration) " + // - "from Call c "); - assertQuery("select p.name, sum(c.duration) " + // - "from Call c " + // - "join c.phone ph " + // - "join ph.person p " + // - "group by p.name"); - assertQuery("select p, sum(c.duration) " + // - "from Call c " + // - "join c.phone ph " + // - "join ph.person p " + // - "group by p"); - assertQuery("select p.name, sum(c.duration) " + // - "from Call c " + // - "join c.phone ph " + // - "join ph.person p " + // - "group by p.name " + // - "having sum(c.duration) > 1000"); - assertQuery("select p.name from Person p " + // - "union " + // - "select p.nickName from Person p where p.nickName is not null"); - assertQuery("select p " + // - "from Person p " + // - "order by p.name"); - assertQuery("select p.name, sum(c.duration) as total " + // - "from Call c " + // - "join c.phone ph " + // - "join ph.person p " + // - "group by p.name " + // - "order by total"); - assertQuery("select c " + // - "from Call c " + // - "join c.phone p " + // - "order by p.number " + // - "limit 50"); - assertQuery("select c " + // - "from Call c " + // - "join c.phone p " + // - "order by p.number " + // - "fetch first 50 rows only"); - assertQuery("select p " + // - "from Phone p " + // - "join fetch p.calls " + // - "order by p " + // - "limit 50"); - } -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JSqlParserQueryEnhancerUnitTests.java index 96411755fb..e756ea0273 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 @@ -39,16 +39,17 @@ class JSqlParserQueryEnhancerUnitTests extends QueryEnhancerTckTests { @Override - QueryEnhancer createQueryEnhancer(DeclaredQuery declaredQuery) { - return new JSqlParserQueryEnhancer(declaredQuery); + QueryEnhancer createQueryEnhancer(DeclaredQuery query) { + return new JSqlParserQueryEnhancer(query); } @Test // GH-3546 void shouldApplySorting() { - QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.of("SELECT e FROM Employee e", true)); + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.jpqlQuery("SELECT e FROM Employee e")); - String sql = enhancer.applySorting(Sort.by("foo", "bar")); + String sql = enhancer.rewrite(new DefaultQueryRewriteInformation(Sort.by("foo", "bar"), + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory()))); assertThat(sql).isEqualTo("SELECT e FROM Employee e ORDER BY e.foo ASC, e.bar ASC"); } @@ -56,7 +57,7 @@ void shouldApplySorting() { @Test // GH-3886 void shouldApplySortingWithNullsPrecedence() { - QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.of("SELECT e FROM Employee e", true)); + 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), @@ -69,15 +70,15 @@ void shouldApplySortingWithNullsPrecedence() { @Test // GH-3707 void countQueriesShouldConsiderPrimaryTableAlias() { - QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.of(""" + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery(""" SELECT DISTINCT a.*, b.b1 FROM TableA a JOIN TableB b ON a.b = b.b LEFT JOIN TableC c ON b.c = c.c ORDER BY b.b1, a.a1, a.a2 - """, true)); + """)); - String sql = enhancer.createCountQueryFor(); + String sql = enhancer.createCountQueryFor(null); assertThat(sql).startsWith("SELECT count(DISTINCT a.*) FROM TableA a"); } @@ -97,16 +98,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(); @@ -120,16 +121,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(); @@ -147,16 +148,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(); @@ -167,16 +168,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(); @@ -188,18 +188,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().toLowerCase()).isEqualToIgnoringWhitespace( + 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.applySorting(Sort.by("day").descending())).endsWith("ORDER BY a.day DESC"); - assertThat(queryEnhancer.getJoinAliases()).isEmpty(); + assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("day").descending()))) + .endsWith("ORDER BY a.day DESC"); assertThat(queryEnhancer.detectAlias()).isEqualToIgnoringCase("a"); assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase("day, value"); assertThat(queryEnhancer.hasConstructorExpression()).isFalse(); @@ -211,18 +211,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().toLowerCase()).isEqualToIgnoringWhitespace( + 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.applySorting(Sort.by("day").descending())).endsWith("ORDER BY a.day DESC"); - assertThat(queryEnhancer.getJoinAliases()).isEmpty(); + assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("day").descending()))) + .endsWith("ORDER BY a.day DESC"); assertThat(queryEnhancer.detectAlias()).isEqualToIgnoringCase("a"); assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase("day, value"); assertThat(queryEnhancer.hasConstructorExpression()).isFalse(); @@ -231,31 +231,41 @@ void multipleWithStatementsWorks() { @Test // GH-3038 void truncateStatementShouldWork() { - StringQuery stringQuery = new StringQuery("TRUNCATE TABLE foo", true); - QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery); + DefaultEntityQuery query = new TestEntityQuery("TRUNCATE TABLE foo", true); + QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query); - assertThat(stringQuery.getAlias()).isNull(); - assertThat(stringQuery.getProjection()).isEmpty(); - assertThat(stringQuery.hasConstructorExpression()).isFalse(); + assertThat(query.getAlias()).isNull(); + assertThat(query.getProjection()).isEmpty(); + assertThat(query.hasConstructorExpression()).isFalse(); - assertThat(queryEnhancer.applySorting(Sort.by("day").descending())).isEqualTo("TRUNCATE TABLE foo"); - assertThat(queryEnhancer.getJoinAliases()).isEmpty(); + assertThat(queryEnhancer.rewrite(getRewriteInformation(Sort.by("day").descending()))) + .isEqualTo("TRUNCATE TABLE foo"); assertThat(queryEnhancer.detectAlias()).isNull(); assertThat(queryEnhancer.getProjection()).isEmpty(); assertThat(queryEnhancer.hasConstructorExpression()).isFalse(); } + @Test // GH-3869 + void shouldWorkWithParenthesedSelect() { + + DefaultEntityQuery query = new TestEntityQuery("(SELECT is_contained_in(:innerId, :outerId))", true); + QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query); + + assertThat(query.getQueryString()).isEqualTo("(SELECT is_contained_in(:innerId, :outerId))"); + assertThat(query.getAlias()).isNull(); + assertThat(queryEnhancer.getProjection()).isEqualTo("is_contained_in(:innerId, :outerId)"); + } + @ParameterizedTest // GH-2641 @MethodSource("mergeStatementWorksSource") - void mergeStatementWorksWithJSqlParser(String query, String alias) { + void mergeStatementWorksWithJSqlParser(String queryString, String alias) { - StringQuery stringQuery = new StringQuery(query, true); - QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery); + DefaultEntityQuery query = new TestEntityQuery(queryString, true); + QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query); assertThat(queryEnhancer.detectAlias()).isEqualTo(alias); - assertThat(QueryUtils.detectAlias(query)).isNull(); + assertThat(QueryUtils.detectAlias(queryString)).isNull(); - assertThat(queryEnhancer.getJoinAliases()).isEmpty(); assertThat(queryEnhancer.detectAlias()).isEqualTo(alias); assertThat(queryEnhancer.getProjection()).isEmpty(); assertThat(queryEnhancer.hasConstructorExpression()).isFalse(); @@ -272,17 +282,9 @@ static Stream mergeStatementWorksSource() { null)); } - @Test // GH-3869 - void shouldWorkWithParenthesedSelect() { - - String query = "(SELECT is_contained_in(:innerId, :outerId))"; - - StringQuery stringQuery = new StringQuery(query, true); - QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery); - - assertThat(stringQuery.getQueryString()).isEqualTo(query); - assertThat(stringQuery.getAlias()).isNull(); - assertThat(queryEnhancer.getProjection()).isEqualTo("is_contained_in(:innerId, :outerId)"); + private static DefaultQueryRewriteInformation getRewriteInformation(Sort sort) { + return new DefaultQueryRewriteInformation(sort, + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())); } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsTests.java index aebad09360..937568e01d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/Jpa21UtilsTests.java @@ -33,12 +33,12 @@ import org.assertj.core.api.AbstractAssert; import org.assertj.core.api.Assertions; import org.assertj.core.api.SoftAssertions; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.repository.EntityGraph.EntityGraphType; -import org.springframework.lang.Nullable; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; import org.springframework.transaction.annotation.Transactional; @@ -191,8 +191,7 @@ void allowsEmptyGraph() { /** * Lookup the {@link AttributeNode} with given {@literal nodeName} in the {@link List} of given {@literal nodes}. */ - @Nullable - static AttributeNode findNode(String nodeName, List> nodes) { + static @Nullable AttributeNode findNode(String nodeName, List> nodes) { if (CollectionUtils.isEmpty(nodes)) { return null; @@ -211,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/JpaCountQueryCreatorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java index 9afcf27d56..d44c40301c 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaCountQueryCreatorIntegrationTests.java @@ -19,21 +19,17 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; -import jakarta.persistence.TypedQuery; import java.lang.reflect.Method; import java.util.List; -import org.hibernate.query.spi.SqmQuery; -import org.hibernate.query.sqm.tree.expression.SqmDistinct; -import org.hibernate.query.sqm.tree.expression.SqmFunction; -import org.hibernate.query.sqm.tree.select.SqmSelectClause; -import org.hibernate.query.sqm.tree.select.SqmSelectStatement; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.data.jpa.domain.sample.Role; import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.provider.PersistenceProvider; +import org.springframework.data.jpa.repository.support.JpqlQueryTemplates; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.Repository; import org.springframework.data.repository.core.support.AbstractRepositoryMetadata; @@ -63,25 +59,15 @@ void distinctFlagOnCountQueryIssuesCountDistinct() throws Exception { AbstractRepositoryMetadata.getMetadata(SomeRepository.class), new SpelAwareProxyProjectionFactory(), provider); PartTree tree = new PartTree("findDistinctByRolesIn", User.class); - ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider(entityManager.getCriteriaBuilder(), - queryMethod.getParameters(), EscapeCharacter.DEFAULT); + ParameterMetadataProvider metadataProvider = new ParameterMetadataProvider( + queryMethod.getParameters(), EscapeCharacter.DEFAULT, JpqlQueryTemplates.UPPER); JpaCountQueryCreator creator = new JpaCountQueryCreator(tree, queryMethod.getResultProcessor().getReturnedType(), - entityManager.getCriteriaBuilder(), metadataProvider); - - TypedQuery 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/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..43452f3977 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryEnhancerUnitTests.java @@ -0,0 +1,57 @@ +/* + * 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"); + } + + 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 6d93f6ae9f..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 @@ -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 a8205cea35..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 @@ -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,7 +91,7 @@ void setUp() { void invalidAnnotatedQueryCausesException() throws Exception { QueryLookupStrategy strategy = JpaQueryLookupStrategy.create(em, queryMethodFactory, Key.CREATE_IF_NOT_FOUND, - EVALUATION_CONTEXT_PROVIDER, new BeanFactoryQueryRewriterProvider(beanFactory), EscapeCharacter.DEFAULT); + CONFIG); Method method = UserRepository.class.getMethod("findByFoo", String.class); RepositoryMetadata metadata = new DefaultRepositoryMetadata(UserRepository.class); @@ -101,7 +103,7 @@ void invalidAnnotatedQueryCausesException() throws Exception { void considersNamedCountQuery() throws Exception { QueryLookupStrategy strategy = JpaQueryLookupStrategy.create(em, queryMethodFactory, Key.CREATE_IF_NOT_FOUND, - EVALUATION_CONTEXT_PROVIDER, new BeanFactoryQueryRewriterProvider(beanFactory), EscapeCharacter.DEFAULT); + CONFIG); when(namedQueries.hasQuery("foo.count")).thenReturn(true); when(namedQueries.getQuery("foo.count")).thenReturn("select count(foo) from Foo foo"); @@ -123,7 +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"); @@ -142,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); @@ -155,12 +157,12 @@ 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(value=…) instead to apply sorting or remove the 'Sort' parameter."); @@ -180,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); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryRewriteIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryRewriteIntegrationTests.java index 9637785e39..2d44dbf0a5 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryRewriteIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpaQueryRewriteIntegrationTests.java @@ -15,8 +15,7 @@ */ package org.springframework.data.jpa.repository.query; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.entry; +import static org.assertj.core.api.Assertions.*; import java.util.HashMap; import java.util.LinkedHashSet; @@ -27,6 +26,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.ComponentScan; @@ -43,8 +43,11 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; +import org.springframework.test.util.ReflectionTestUtils; /** * Unit tests for repository with {@link Query} and {@link QueryRewriter}. @@ -57,6 +60,7 @@ class JpaQueryRewriteIntegrationTests { @Autowired private UserRepositoryWithRewriter repository; + @Autowired private JpaRepositoryFactoryBean factoryBean; // Results static final String ORIGINAL_QUERY = "original query"; @@ -71,6 +75,14 @@ void setUp() { repository.deleteAll(); } + @Test + void shouldConfigureQueryEnhancerSelector() { + + JpaRepositoryFactory factory = (JpaRepositoryFactory) ReflectionTestUtils.getField(factoryBean, "factory"); + + assertThat(factory).extracting("queryEnhancerSelector").isInstanceOf(MyQueryEnhancerSelector.class); + } + @Test void nativeQueryShouldHandleRewrites() { @@ -228,7 +240,8 @@ private static String replaceAlias(String query, Sort sort) { @ImportResource("classpath:infrastructure.xml") @EnableJpaRepositories(considerNestedRepositories = true, basePackageClasses = UserRepositoryWithRewriter.class, // includeFilters = @ComponentScan.Filter(value = { UserRepositoryWithRewriter.class }, - type = FilterType.ASSIGNABLE_TYPE)) + type = FilterType.ASSIGNABLE_TYPE), + queryEnhancerSelector = MyQueryEnhancerSelector.class) static class JpaRepositoryConfig { @Bean @@ -237,4 +250,10 @@ QueryRewriter queryRewriter() { } } + + static class MyQueryEnhancerSelector extends QueryEnhancerSelector.DefaultQueryEnhancerSelector { + public MyQueryEnhancerSelector() { + super(QueryEnhancerFactories.fallback(), DefaultQueryEnhancerSelector.jpql()); + } + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlComplianceTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlComplianceTests.java deleted file mode 100644 index 81722f9b90..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlComplianceTests.java +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2024-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.query; - -import static org.assertj.core.api.Assertions.*; - -import org.junit.jupiter.api.Test; - -/** - * Test to verify compliance of {@link JpqlParser} with standard SQL. Other than {@link JpqlSpecificationTests} tests in - * this class check that the parser follows a lenient approach and does not error on well known concepts like numeric - * suffix. - * - * @author Christoph Strobl - */ -class JpqlComplianceTests { - - private static String parseWithoutChanges(String query) { - - JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser.parseQuery(query); - - return QueryRenderer.render(new JpqlQueryRenderer().visit(parser.getContext())); - } - - private void assertQuery(String query) { - - String slimmedDownQuery = reduceWhitespace(query); - assertThat(parseWithoutChanges(slimmedDownQuery)).isEqualTo(slimmedDownQuery); - } - - private String reduceWhitespace(String original) { - - return original // - .replaceAll("[ \\t\\n]{1,}", " ") // - .trim(); - } - - @Test // GH-3277 - void numericLiterals() { - - assertQuery("SELECT e FROM Employee e WHERE e.id = 1234"); - assertQuery("SELECT e FROM Employee e WHERE e.id = 1234L"); - assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14"); - assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14F"); - assertQuery("SELECT s FROM Stat s WHERE s.ratio > 3.14e32D"); - } - - @Test // GH-3308 - void newWithStrings() { - assertQuery("select new com.example.demo.SampleObject(se.id, se.sampleValue, \"java\") from SampleEntity se"); - } - -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java index d0c8fa1305..a82aaf7581 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlDtoQueryTransformerUnitTests.java @@ -15,101 +15,27 @@ */ package org.springframework.data.jpa.repository.query; -import static org.assertj.core.api.Assertions.*; - -import java.lang.reflect.Method; - -import org.junit.jupiter.api.Test; +import org.antlr.v4.runtime.tree.ParseTreeVisitor; import org.springframework.data.domain.Sort; -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; /** * Unit tests for {@link DtoProjectionTransformerDelegate}. * * @author Mark Paluch */ -class JpqlDtoQueryTransformerUnitTests { - - JpaQueryMethod method = getMethod("dtoProjection"); - - @Test // GH-3076 - void shouldTranslateSingleProjectionToDto() { - - JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser.parseQuery("SELECT p from Person p"); +class JpqlDtoQueryTransformerUnitTests extends AbstractDtoQueryTransformerUnitTests { - QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); - - assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo( - "SELECT new org.springframework.data.jpa.repository.query.JpqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar) from Person p"); + @Override + JpaQueryEnhancer.JpqlQueryParser parse(String query) { + return JpaQueryEnhancer.JpqlQueryParser.parseQuery(query); } - @Test // GH-3076 - void shouldRewriteQueriesWithSubselect() { - - JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser - .parseQuery("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.JpqlDtoQueryTransformerUnitTests$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 shouldNotTranslateConstructorExpressionQuery() { - - JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser - .parseQuery("SELECT NEW Foo(p) from Person p"); - - QueryTokenStream visit = getTransformer(parser).visit(parser.getContext()); - - assertThat(QueryRenderer.TokenRenderer.render(visit)).isEqualTo("SELECT NEW Foo(p) from Person p"); - } - - @Test - void shouldTranslatePropertySelectionToDto() { - - JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser - .parseQuery("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.JpqlDtoQueryTransformerUnitTests$MyRecord(p.foo, p.bar, sum(p.age)) 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); - } - } - - private JpqlSortedQueryTransformer getTransformer(JpaQueryEnhancer.JpqlQueryParser parser) { + @Override + ParseTreeVisitor getTransformer(JpaQueryEnhancer.JpqlQueryParser parser, QueryMethod method) { return new JpqlSortedQueryTransformer(Sort.unsorted(), parser.getQueryInformation(), method.getResultProcessor().getReturnedType()); } - interface MyRepo extends Repository { - - MyRecord dtoProjection(); - } - - record Person(String id) { - - } - - record MyRecord(String foo, String bar) { - - } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlParserQueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlParserQueryEnhancerUnitTests.java index 8b6385e65d..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 @@ -25,14 +25,14 @@ * * @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 d3daa9e723..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 @@ -44,7 +44,7 @@ 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) { @@ -70,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 */ @@ -348,6 +623,38 @@ select cast(i as string) from Item i where cast(i.date as date) <= cast(:current 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 void pathExpressionsNamedParametersExample() { @@ -953,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() { @@ -987,23 +1302,31 @@ 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-3061 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(""" @@ -1034,6 +1357,59 @@ void powerShouldBeLegalInAQuery() { assertQuery("select e.power.id from MyEntity e"); } + @Test // GH-3136 + void doublePipeShouldBeValidAsAStringConcatOperator() { + + assertQuery(""" + select e.name || ' ' || e.title + from Employee e + """); + } + + @Test // GH-3136 + void combinedSelectStatementsShouldWork() { + + assertQuery(""" + select e from Employee e where e.last_name = 'Baggins' + intersect + select e from Employee e where e.first_name = 'Samwise' + union + select e from Employee e where e.home = 'The Shire' + except + select e from Employee e where e.home = 'Isengard' + """); + } + + @Disabled + @Test // GH-3136 + void additionalStringOperationsShouldWork() { + + assertQuery(""" + select + replace(e.name, 'Baggins', 'Proudfeet'), + left(e.role, 4), + right(e.home, 5), + cast(e.distance_from_home, int) + from Employee e + """); + } + + @Test // GH-3136 + void orderByWithNullsFirstOrLastShouldWork() { + + assertQuery(""" + select a + from Element a + order by mutationAm desc nulls first + """); + + assertQuery(""" + select a + from Element a + order by mutationAm desc nulls last + """); + } + @ParameterizedTest // GH-3342 @ValueSource(strings = { "select 1 as value from User u", "select -1 as value from User u", "select +1 as value from User u", "select +1 * -100 as value from User u", @@ -1065,6 +1441,13 @@ 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() { @@ -1072,6 +1455,31 @@ void reservedWordsShouldWork() { 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 147477fc2f..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 @@ -20,6 +20,7 @@ import java.util.stream.Stream; import org.assertj.core.api.SoftAssertions; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -28,7 +29,8 @@ import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort.Order; import org.springframework.data.jpa.domain.JpaSort; -import org.springframework.lang.Nullable; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.query.ReturnedType; /** * Verify that JPQL queries are properly transformed through the {@link JpaQueryEnhancer} and the @@ -81,13 +83,11 @@ void nullFirstLastSorting() { 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").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"); + assertThat(createQueryFor(original, Sort.by(Order.desc("lastName").nullsFirst()))).startsWith(original) + .endsWithIgnoringCase("e.lastName DESC NULLS FIRST"); } @Test @@ -103,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() { @@ -116,6 +142,12 @@ 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 applyCountToAlreadySortedQuery() { @@ -142,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); } @@ -182,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"); @@ -192,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"); @@ -209,6 +255,11 @@ void detectsAliasCorrectly() { 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"); + 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 @@ -222,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 @@ -485,9 +538,6 @@ void detectsAliasWithGroupAndOrderBy() { @Test // DATAJPA-1500 void createCountQuerySupportsWhitespaceCharacters() { - // - // - // assertThat(createCountQueryFor(""" select user from User user where user.age = 18 @@ -563,10 +613,6 @@ void appliesSortCorrectlyForSimpleField() { @Test void createCountQuerySupportsLineBreakRightAfterDistinct() { - // - // - // - // assertThat(createCountQueryFor(""" select distinct @@ -590,7 +636,6 @@ void detectsAliasWithGroupAndOrderByWithLineBreaks() { .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"); @@ -701,6 +746,22 @@ void createsCountQueryUsingAliasCorrectly() { "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() { @@ -784,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( // @@ -799,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) { @@ -823,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 289e522455..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/JpqlSpecificationTests.java +++ /dev/null @@ -1,909 +0,0 @@ -/* - * Copyright 2022-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.query; - -import static org.assertj.core.api.Assertions.*; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import org.springframework.data.jpa.repository.query.QueryRenderer.TokenRenderer; - -/** - * Tests built around examples of JPQL found in the JPA spec - * https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc
      - *
      - * IMPORTANT: Purely verifies the parser without any transformations. - * - * @author Greg Turnquist - * @since 3.1 - */ -class JpqlSpecificationTests { - - private static final String SPEC_FAULT = "Disabled due to spec fault> "; - - /** - * Parse the query using {@link HqlParser} then run it through the query-preserving {@link HqlQueryRenderer}. - */ - private static String parseWithoutChanges(String query) { - - JpaQueryEnhancer.JpqlQueryParser parser = JpaQueryEnhancer.JpqlQueryParser.parseQuery(query); - - return TokenRenderer.render(new JpqlQueryRenderer().visit(parser.getContext())); - } - - private void assertQuery(String query) { - - String slimmedDownQuery = reduceWhitespace(query); - assertThat(parseWithoutChanges(slimmedDownQuery)).isEqualTo(slimmedDownQuery); - } - - private String reduceWhitespace(String original) { - - return original // - .replaceAll("[ \\t\\n]{1,}", " ") // - .trim(); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example - */ - @Test - void joinExample1() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order AS o JOIN o.lineItems AS l - WHERE l.shipped = FALSE - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#example - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#identification-variables - */ - @Test - void joinExample2() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l JOIN l.product p - WHERE p.productType = 'office_supplies' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#range-variable-declarations - */ - @Test - void rangeVariableDeclarations() { - - assertQuery(""" - SELECT DISTINCT o1 - FROM Order o1, Order o2 - WHERE o1.quantity > o2.quantity AND - o2.customer.lastname = 'Smith' AND - o2.customer.firstname = 'John' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample1() { - - assertQuery(""" - SELECT i.name, VALUE(p) - FROM Item i JOIN i.photos p - WHERE KEY(p) LIKE '%egret' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample2() { - - assertQuery(""" - SELECT i.name, p - FROM Item i JOIN i.photos p - WHERE KEY(p) LIKE '%egret' - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample3() { - - assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo.phones p - """); - } - - /** - * @see https://github.com/jakartaee/persistence/blob/master/spec/src/main/asciidoc/ch04-query-language.adoc#path-expressions - */ - @Test - void pathExpressionsExample4() { - - assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE e.contactInfo.address.zipcode = '95054' - """); - } - - @Test - void pathExpressionSyntaxExample1() { - - assertQuery(""" - SELECT DISTINCT l.product - FROM Order AS o JOIN o.lineItems l - """); - } - - @Test - void joinsExample1() { - - assertQuery(""" - SELECT c FROM Customer c, Employee e WHERE c.hatsize = e.shoesize - """); - } - - @Test - void joinsExample2() { - - assertQuery(""" - SELECT c FROM Customer c JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInnerExample() { - - assertQuery(""" - SELECT c FROM Customer c INNER JOIN c.orders o WHERE c.status = 1 - """); - } - - @Test - void joinsInExample() { - - assertQuery(""" - SELECT OBJECT(c) FROM Customer c, IN(c.orders) o WHERE c.status = 1 - """); - } - - @Test - void doubleJoinExample() { - - assertQuery(""" - SELECT p.vendor - FROM Employee e JOIN e.contactInfo c JOIN c.phones p - WHERE c.address.zipcode = '95054' - """); - } - - @Test - void leftJoinExample() { - - assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - GROUP BY s.name - """); - } - - @Test - void leftJoinOnExample() { - - assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - ON p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinWhereExample() { - - assertQuery(""" - SELECT s.name, COUNT(p) - FROM Suppliers s LEFT JOIN s.products p - WHERE p.status = 'inStock' - GROUP BY s.name - """); - } - - @Test - void leftJoinFetchExample() { - - assertQuery(""" - SELECT d - FROM Department d LEFT JOIN FETCH d.employees - WHERE d.deptno = 1 - """); - } - - @Test - void collectionMemberExample() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void collectionMemberInExample() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o, IN(o.lineItems) l - WHERE l.product.productType = 'office_supplies' - """); - } - - @Test - void fromClauseExample() { - - assertQuery(""" - SELECT o - FROM Order AS o JOIN o.lineItems l JOIN l.product p - """); - } - - @Test - void fromClauseDowncastingExample1() { - - assertQuery(""" - SELECT b.name, b.ISBN - FROM Order o JOIN TREAT(o.product AS Book) b - """); - } - - @Test - void fromClauseDowncastingExample2() { - - assertQuery(""" - SELECT e FROM Employee e JOIN TREAT(e.projects AS LargeProject) lp - WHERE lp.budget > 1000 - """); - } - - /** - * @see #fromClauseDowncastingExample3fixed() - */ - @Test - @Disabled(SPEC_FAULT + "Use double-quotes when it should be using single-quotes for a string literal") - void fromClauseDowncastingExample3_SPEC_BUG() { - - assertQuery(""" - SELECT e FROM Employee e JOIN e.projects p - WHERE TREAT(p AS LargeProject).budget > 1000 - OR TREAT(p AS SmallProject).name LIKE 'Persist%' - OR p.description LIKE "cost overrun" - """); - } - - @Test - void fromClauseDowncastingExample3fixed() { - - assertQuery(""" - SELECT e FROM Employee e JOIN e.projects p - WHERE TREAT(p AS LargeProject).budget > 1000 - OR TREAT(p AS SmallProject).name LIKE 'Persist%' - OR p.description LIKE 'cost overrun' - """); - } - - @Test - void fromClauseDowncastingExample4() { - - assertQuery(""" - SELECT e FROM Employee e - WHERE TREAT(e AS Exempt).vacationDays > 10 - OR TREAT(e AS Contractor).hours > 100 - """); - } - - @Test - void pathExpressionsNamedParametersExample() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE c.status = :stat - """); - } - - @Test - void betweenExpressionsExample() { - - assertQuery(""" - SELECT t - FROM CreditCard c JOIN c.transactionHistory t - WHERE c.holder.name = 'John Doe' AND INDEX(t) BETWEEN 0 AND 9 - """); - } - - @Test - void isEmptyExample() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void memberOfExample() { - - assertQuery(""" - SELECT p - FROM Person p - WHERE 'Joe' MEMBER OF p.nicknames - """); - } - - @Test - void existsSubSelectExample1() { - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void allExample() { - - assertQuery(""" - SELECT emp - FROM Employee emp - WHERE emp.salary > ALL (SELECT m.salary - FROM Manager m - WHERE m.department = emp.department) - """); - } - - @Test - void existsSubSelectExample2() { - - assertQuery(""" - SELECT DISTINCT emp - FROM Employee emp - WHERE EXISTS (SELECT spouseEmp - FROM Employee spouseEmp - WHERE spouseEmp = emp.spouse) - """); - } - - @Test - void subselectNumericComparisonExample1() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE (SELECT AVG(o.price) FROM c.orders o) > 100 - """); - } - - @Test - void subselectNumericComparisonExample2() { - - assertQuery(""" - SELECT goodCustomer - FROM Customer goodCustomer - WHERE goodCustomer.balanceOwed < (SELECT AVG(c.balanceOwed) / 2.0 FROM Customer c) - """); - } - - @Test - void indexExample() { - - assertQuery(""" - SELECT w.name - FROM Course c JOIN c.studentWaitlist w - WHERE c.name = 'Calculus' - AND INDEX(w) = 0 - """); - } - - /** - * @see #functionInvocationExampleWithCorrection() - */ - @Test - @Disabled(SPEC_FAULT + "FUNCTION calls needs a comparator") - void functionInvocationExample_SPEC_BUG() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) - """); - } - - @Test - void functionInvocationExampleWithCorrection() { - - assertQuery(""" - SELECT c - FROM Customer c - WHERE FUNCTION('hasGoodCredit', c.balance, c.creditLimit) = TRUE - """); - } - - @Test - void updateCaseExample1() { - - assertQuery(""" - UPDATE Employee e - SET e.salary = - CASE WHEN e.rating = 1 THEN e.salary * 1.1 - WHEN e.rating = 2 THEN e.salary * 1.05 - ELSE e.salary * 1.01 - END - """); - } - - @Test - void updateCaseExample2() { - - assertQuery(""" - UPDATE Employee e - SET e.salary = - CASE e.rating WHEN 1 THEN e.salary * 1.1 - WHEN 2 THEN e.salary * 1.05 - ELSE e.salary * 1.01 - END - """); - } - - @Test - void selectCaseExample1() { - - assertQuery(""" - SELECT e.name, - CASE TYPE(e) WHEN Exempt THEN 'Exempt' - WHEN Contractor THEN 'Contractor' - WHEN Intern THEN 'Intern' - ELSE 'NonExempt' - END - FROM Employee e - WHERE e.dept.name = 'Engineering' - """); - } - - @Test - void selectCaseExample2() { - - assertQuery(""" - SELECT e.name, - f.name, - CONCAT(CASE WHEN f.annualMiles > 50000 THEN 'Platinum ' - WHEN f.annualMiles > 25000 THEN 'Gold ' - ELSE '' - END, - 'Frequent Flyer') - FROM Employee e JOIN e.frequentFlierPlan f - """); - } - - @Test - void theRest() { - - assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (Exempt, Contractor) - """); - } - - @Test - void theRest2() { - - assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN (:empType1, :empType2) - """); - } - - @Test - void theRest3() { - - assertQuery(""" - SELECT e - FROM Employee e - WHERE TYPE(e) IN :empTypes - """); - } - - @Test - void theRest4() { - - assertQuery(""" - SELECT TYPE(e) - FROM Employee e - WHERE TYPE(e) <> Exempt - """); - } - - @Test - void theRest5() { - - assertQuery(""" - SELECT c.status, AVG(c.filledOrderCount), COUNT(c) - FROM Customer c - GROUP BY c.status - HAVING c.status IN (1, 2) - """); - } - - @Test - void theRest6() { - - assertQuery(""" - SELECT c.country, COUNT(c) - FROM Customer c - GROUP BY c.country - HAVING COUNT(c) > 30 - """); - } - - @Test - void theRest7() { - - assertQuery(""" - SELECT c, COUNT(o) - FROM Customer c JOIN c.orders o - GROUP BY c - HAVING COUNT(o) >= 5 - """); - } - - @Test - void theRest8() { - - assertQuery(""" - SELECT c.id, c.status - FROM Customer c JOIN c.orders o - WHERE o.count > 100 - """); - } - - @Test - void theRest9() { - - assertQuery(""" - SELECT v.location.street, KEY(i).title, VALUE(i) - FROM VideoStore v JOIN v.videoInventory i - WHERE v.location.zipcode = '94301' AND VALUE(i) > 0 - """); - } - - @Test - void theRest10() { - - assertQuery(""" - SELECT o.lineItems FROM Order AS o - """); - } - - @Test - void theRest11() { - - assertQuery(""" - SELECT c, COUNT(l) AS itemCount - FROM Customer c JOIN c.Orders o JOIN o.lineItems l - WHERE c.address.state = 'CA' - GROUP BY c - ORDER BY itemCount - """); - } - - @Test - void theRest12() { - - assertQuery(""" - SELECT NEW com.acme.example.CustomerDetails(c.id, c.status, o.count) - FROM Customer c JOIN c.orders o - WHERE o.count > 100 - """); - } - - @Test - void theRest13() { - - assertQuery(""" - SELECT e.address AS addr - FROM Employee e - """); - } - - @Test - void theRest14() { - - assertQuery(""" - SELECT AVG(o.quantity) FROM Order o - """); - } - - @Test - void theRest15() { - - assertQuery(""" - SELECT SUM(l.price) - FROM Order o JOIN o.lineItems l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - """); - } - - @Test - void theRest16() { - - assertQuery(""" - SELECT COUNT(o) FROM Order o - """); - } - - @Test - void theRest17() { - - assertQuery(""" - SELECT COUNT(l.price) - FROM Order o JOIN o.lineItems l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - """); - } - - @Test - void theRest18() { - - assertQuery(""" - SELECT COUNT(l) - FROM Order o JOIN o.lineItems l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' AND l.price IS NOT NULL - """); - } - - @Test - void theRest19() { - - assertQuery(""" - SELECT o - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' - ORDER BY o.quantity DESC, o.totalcost - """); - } - - @Test - void theRest20() { - - assertQuery(""" - SELECT o.quantity, a.zipcode - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' - ORDER BY o.quantity, a.zipcode - """); - } - - @Test - void theRest21() { - - assertQuery(""" - SELECT o.quantity, o.cost * 1.08 AS taxedCost, a.zipcode - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' AND a.county = 'Santa Clara' - ORDER BY o.quantity, taxedCost, a.zipcode - """); - } - - @Test - void theRest22() { - - assertQuery(""" - SELECT AVG(o.quantity) as q, a.zipcode - FROM Customer c JOIN c.orders o JOIN c.address a - WHERE a.state = 'CA' - GROUP BY a.zipcode - ORDER BY q DESC - """); - } - - @Test - void theRest23() { - - assertQuery(""" - SELECT p.product_name - FROM Order o JOIN o.lineItems l JOIN l.product p JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - ORDER BY p.price - """); - } - - /** - * This query is specifically dubbed illegal in the spec. It may actually be failing for a different reason. - */ - @Test - void theRest24() { - - assertThatExceptionOfType(BadJpqlGrammarException.class).isThrownBy(() -> { - assertQuery(""" - SELECT p.product_name - FROM Order o, IN(o.lineItems) l JOIN o.customer c - WHERE c.lastname = 'Smith' AND c.firstname = 'John' - ORDER BY o.quantity - """); - }); - } - - @Test - void theRest25() { - - assertQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - """); - } - - @Test - void theRest26() { - - assertQuery(""" - DELETE - FROM Customer c - WHERE c.status = 'inactive' - AND c.orders IS EMPTY - """); - } - - @Test - void theRest27() { - - assertQuery(""" - UPDATE Customer c - SET c.status = 'outstanding' - WHERE c.balance < 10000 - """); - } - - @Test - void theRest28() { - - assertQuery(""" - UPDATE Employee e - SET e.address.building = 22 - WHERE e.address.building = 14 - AND e.address.city = 'Santa Clara' - AND e.project = 'Jakarta EE' - """); - } - - @Test - void theRest29() { - - assertQuery(""" - SELECT o - FROM Order o - """); - } - - @Test - void theRest30() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress.state = 'CA' - """); - } - - @Test - void theRest31() { - - assertQuery(""" - SELECT DISTINCT o.shippingAddress.state - FROM Order o - """); - } - - @Test - void theRest32() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - """); - } - - @Test - void theRest33() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS NOT EMPTY - """); - } - - @Test - void theRest34() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.lineItems IS EMPTY - """); - } - - @Test - void theRest35() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.shipped = FALSE - """); - } - - @Test - void theRest36() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE - NOT (o.shippingAddress.state = o.billingAddress.state AND - o.shippingAddress.city = o.billingAddress.city AND - o.shippingAddress.street = o.billingAddress.street) - """); - } - - @Test - void theRest37() { - - assertQuery(""" - SELECT o - FROM Order o - WHERE o.shippingAddress <> o.billingAddress - """); - } - - @Test - void theRest38() { - - assertQuery(""" - SELECT DISTINCT o - FROM Order o JOIN o.lineItems l - WHERE l.product.name = ?1 - """); - } -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java index 6f1692142d..d438cdf9a6 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedOrIndexedQueryParameterSetterUnitTests.java @@ -18,6 +18,7 @@ import static jakarta.persistence.TemporalType.*; import static java.util.Arrays.*; import static org.mockito.Mockito.*; +import static org.springframework.data.jpa.repository.query.QueryParameterSetter.*; import static org.springframework.data.jpa.repository.query.QueryParameterSetter.ErrorHandling.*; import jakarta.persistence.Parameter; @@ -34,7 +35,8 @@ import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.springframework.data.jpa.repository.query.QueryParameterSetter.NamedOrIndexedQueryParameterSetter; + +import org.springframework.data.jpa.repository.query.QueryParameterSetter.*; /** * Unit tests fir {@link NamedOrIndexedQueryParameterSetter}. @@ -79,7 +81,7 @@ void strictErrorHandlingThrowsExceptionForAllVariationsOfParameters() { for (Parameter parameter : parameters) { for (TemporalType temporalType : temporalTypes) { - NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( // + QueryParameterSetter setter = QueryParameterSetter.create( // firstValueExtractor, // parameter, // temporalType // @@ -108,7 +110,7 @@ void lenientErrorHandlingThrowsNoExceptionForAllVariationsOfParameters() { for (Parameter parameter : parameters) { for (TemporalType temporalType : temporalTypes) { - NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( // + QueryParameterSetter setter = QueryParameterSetter.create( // firstValueExtractor, // parameter, // temporalType // @@ -141,7 +143,7 @@ void lenientSetsParameterWhenSuccessIsUnsure() { for (TemporalType temporalType : temporalTypes) { - NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( // + QueryParameterSetter setter = QueryParameterSetter.create( // firstValueExtractor, // new ParameterImpl(null, 11), // parameter position is beyond number of parametes in query (0) temporalType // @@ -171,7 +173,7 @@ void parameterNotSetWhenSuccessImpossible() { for (TemporalType temporalType : temporalTypes) { - NamedOrIndexedQueryParameterSetter setter = new NamedOrIndexedQueryParameterSetter( // + QueryParameterSetter setter = QueryParameterSetter.create( // firstValueExtractor, // new ParameterImpl(null, null), // no position (and no name) makes a success of a setParameter impossible temporalType // diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NamedQueryUnitTests.java index 68cae8bc60..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 @@ -36,11 +36,11 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.provider.QueryExtractor; -import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.projection.ProjectionFactory; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.core.RepositoryMetadata; import org.springframework.data.repository.query.QueryCreationException; +import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.data.util.TypeInformation; /** @@ -55,6 +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; @@ -90,7 +93,7 @@ void rejectsPersistenceProviderIfIncapableOfExtractingQueriesAndPagebleBeingUsed when(em.createNamedQuery(queryMethod.getNamedCountQueryName())).thenThrow(new IllegalArgumentException()); assertThatExceptionOfType(QueryCreationException.class) - .isThrownBy(() -> NamedQuery.lookupFrom(queryMethod, em, QueryRewriter.IdentityQueryRewriter.INSTANCE)); + .isThrownBy(() -> NamedQuery.lookupFrom(queryMethod, em, CONFIG)); } @Test // DATAJPA-142 @@ -102,8 +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, - QueryRewriter.IdentityQueryRewriter.INSTANCE); + NamedQuery query = (NamedQuery) NamedQuery.lookupFrom(queryMethod, em, CONFIG); query.doCreateCountQuery(new JpaParametersParameterAccessor(queryMethod.getParameters(), new Object[1])); verify(em, times(1)).createNamedQuery(queryMethod.getNamedCountQueryName(), Long.class); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java index cf9dab51fb..7768239163 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/NativeJpaQueryUnitTests.java @@ -30,11 +30,9 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.provider.QueryExtractor; import org.springframework.data.jpa.repository.Query; -import org.springframework.data.jpa.repository.QueryRewriter; import org.springframework.data.projection.SpelAwareProxyProjectionFactory; import org.springframework.data.repository.Repository; import org.springframework.data.repository.core.RepositoryMetadata; @@ -72,13 +70,12 @@ void shouldApplySorting() { JpaQueryMethod queryMethod = new JpaQueryMethod(respositoryMethod, repositoryMetadata, projectionFactory, queryExtractor); - Query annotation = AnnotatedElementUtils.getMergedAnnotation(respositoryMethod, Query.class); + NativeJpaQuery query = new NativeJpaQuery(queryMethod, em, queryMethod.getRequiredDeclaredQuery(), + queryMethod.getDeclaredCountQuery(), new JpaQueryConfiguration(QueryRewriterProvider.simple(), + QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT)); + QueryProvider sql = query.getSortedQuery(Sort.by("foo", "bar"), queryMethod.getResultProcessor().getReturnedType()); - NativeJpaQuery query = new NativeJpaQuery(queryMethod, em, annotation.value(), annotation.countQuery(), - QueryRewriter.IdentityQueryRewriter.INSTANCE, ValueExpressionDelegate.create()); - String sql = query.getSortedQueryString(Sort.by("foo", "bar"), queryMethod.getResultProcessor().getReturnedType()); - - assertThat(sql).isEqualTo("SELECT e FROM Employee e order by e.foo asc, e.bar asc"); + assertThat(sql.getQueryString()).isEqualTo("SELECT e FROM Employee e order by e.foo asc, e.bar asc"); } interface TestRepo extends Repository { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java index e80d9a8692..360dcf4be1 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBinderUnitTests.java @@ -274,13 +274,13 @@ private void bind(Method method, Object[] values) { } private void bind(Method method, JpaParameters parameters, Object[] values) { - ParameterBinderFactory.createBinder(parameters).bind(QueryParameterSetter.BindableQuery.from(query), + ParameterBinderFactory.createBinder(parameters, false).bind(QueryParameterSetter.BindableQuery.from(query), getAccessor(method, values), QueryParameterSetter.ErrorHandling.STRICT); } private void bindAndPrepare(Method method, Object[] values) { - ParameterBinderFactory.createBinder(createParameters(method)).bindAndPrepare(query, - new QueryParameterSetter.QueryMetadata(query), getAccessor(method, values)); + ParameterBinderFactory.createBinder(createParameters(method), false).bindAndPrepare(query, + getAccessor(method, values)); } private JpaParametersParameterAccessor getAccessor(Method method, Object... values) { diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java index edcaf0e4ea..f765860a27 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterBindingParserUnitTests.java @@ -18,7 +18,6 @@ import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; -import org.springframework.data.jpa.repository.query.StringQuery.ParameterBindingParser; /** * Unit tests for the {@link ParameterBindingParser}. @@ -68,7 +67,7 @@ void identificationOfParameters() { private void checkHasParameter(SoftAssertions softly, String query, boolean containsParameter, String label) { - StringQuery stringQuery = new StringQuery(query, false); + DefaultEntityQuery stringQuery = new TestEntityQuery(query, false); softly.assertThat(stringQuery.getParameterBindings().size()) // .describedAs(String.format("<%s> (%s)", query, label)) // diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java deleted file mode 100644 index b706551305..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterExpressionProviderTests.java +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright 2017-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.query; - -import static org.assertj.core.api.Assertions.*; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.criteria.CriteriaBuilder; -import jakarta.persistence.criteria.ParameterExpression; - -import java.lang.reflect.Method; - -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.data.jpa.domain.sample.User; -import org.springframework.data.repository.query.DefaultParameters; -import org.springframework.data.repository.query.Parameters; -import org.springframework.data.repository.query.ParametersParameterAccessor; -import org.springframework.data.repository.query.ParametersSource; -import org.springframework.data.repository.query.parser.Part; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -/** - * Integration tests for {@link ParameterMetadataProvider}. - * - * @author Oliver Gierke - * @author Jens Schauder - */ -@ExtendWith(SpringExtension.class) -@ContextConfiguration("classpath:infrastructure.xml") -class ParameterExpressionProviderTests { - - @PersistenceContext EntityManager em; - - @Test // DATADOC-99 - @SuppressWarnings("rawtypes") - void createsParameterExpressionWithMostConcreteType() throws Exception { - - Method method = SampleRepository.class.getMethod("findByIdGreaterThan", int.class); - Parameters parameters = new DefaultParameters(ParametersSource.of(method)); - ParametersParameterAccessor accessor = new ParametersParameterAccessor(parameters, new Object[] { 1 }); - Part part = new Part("IdGreaterThan", User.class); - - CriteriaBuilder builder = em.getCriteriaBuilder(); - ParameterMetadataProvider provider = new ParameterMetadataProvider(builder, accessor, EscapeCharacter.DEFAULT); - ParameterExpression expression = provider.next(part, Comparable.class).getExpression(); - - assertThat(expression.getParameterType()).isEqualTo(Integer.class); - } - - interface SampleRepository { - - User findByIdGreaterThan(int id); - } -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ParameterMetadataProviderIntegrationTests.java index c0f86397d3..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 @@ -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 86a4de3ab2..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 @@ -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 604864545b..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 @@ -17,9 +17,7 @@ */ package org.springframework.data.jpa.repository.query; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.*; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -39,9 +37,11 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; + import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.sample.Role; import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.provider.HibernateUtils; import org.springframework.data.jpa.provider.PersistenceProvider; @@ -50,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; @@ -112,7 +113,7 @@ void recreatesQueryIfNullValueIsGiven(String criteria) throws Exception { Query query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { "Matthews", PageRequest.of(0, 1) })); assertThat(HibernateUtils.getHibernateQuery(query.unwrap(HIBERNATE_NATIVE_QUERY))) - .contains("firstname %s :".formatted(criteria.endsWith("Not") ? "<>" : "=")); + .contains("firstname %s :".formatted(criteria.endsWith("Not") ? "!=" : "=")); query = jpaQuery.createQuery(getAccessor(queryMethod, new Object[] { null, PageRequest.of(0, 1) })); @@ -151,7 +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 @@ -162,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 @@ -170,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 @@ -180,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"); } @@ -191,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"); } @@ -214,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 @@ -226,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 @@ -297,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 99b8a7a730..2f52341214 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactoryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerFactoryUnitTests.java @@ -17,16 +17,7 @@ import static org.assertj.core.api.Assertions.*; -import java.util.stream.Stream; - import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; - -import org.springframework.data.jpa.repository.query.QueryEnhancerFactory.NativeQueryEnhancer; -import org.springframework.data.jpa.util.ClassPathExclusions; -import org.springframework.lang.Nullable; /** * Unit tests for {@link QueryEnhancerFactory}. @@ -41,9 +32,10 @@ class QueryEnhancerFactoryUnitTests { @Test void createsParsingImplementationForNonNativeQuery() { - StringQuery query = new StringQuery("select new com.example.User(u.firstname) from User u", false); + DefaultEntityQuery query = new TestEntityQuery("select new com.example.User(u.firstname) from User u", + false); - QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query); + QueryEnhancer queryEnhancer = QueryEnhancer.create(query); assertThat(queryEnhancer) // .isInstanceOf(JpaQueryEnhancer.class); @@ -56,81 +48,12 @@ void createsParsingImplementationForNonNativeQuery() { @Test void createsJSqlImplementationForNativeQuery() { - StringQuery query = new StringQuery("select * from User", true); + DefaultEntityQuery query = new TestEntityQuery("select * from User", true); - QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query); + QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(query).create(query); assertThat(queryEnhancer) // .isInstanceOf(JSqlParserQueryEnhancer.class); } - @ParameterizedTest // GH-2989 - @MethodSource("nativeEnhancerSelectionArgs") - void createsNativeImplementationAccordingToUserChoice(@Nullable String selection, NativeQueryEnhancer enhancer) { - - assertThat(NativeQueryEnhancer.JSQLPARSER_PRESENT).isTrue(); - - withSystemProperty(NativeQueryEnhancer.NATIVE_PARSER_PROPERTY, selection, () -> { - assertThat(NativeQueryEnhancer.select()).isEqualTo(enhancer); - }); - } - - static Stream nativeEnhancerSelectionArgs() { - return Stream.of(Arguments.of(null, NativeQueryEnhancer.JSQLPARSER), // - Arguments.of("", NativeQueryEnhancer.JSQLPARSER), // - Arguments.of("auto", NativeQueryEnhancer.JSQLPARSER), // - Arguments.of("regex", NativeQueryEnhancer.REGEX), // - Arguments.of("jsqlparser", NativeQueryEnhancer.JSQLPARSER)); - } - - @ParameterizedTest // GH-2989 - @MethodSource("nativeEnhancerExclusionSelectionArgs") - @ClassPathExclusions(packages = { "net.sf.jsqlparser.parser" }) - void createsNativeImplementationAccordingWithoutJsqlParserToUserChoice(@Nullable String selection, - NativeQueryEnhancer enhancer) { - - assertThat(NativeQueryEnhancer.JSQLPARSER_PRESENT).isFalse(); - - withSystemProperty(NativeQueryEnhancer.NATIVE_PARSER_PROPERTY, selection, () -> { - assertThat(NativeQueryEnhancer.select()).isEqualTo(enhancer); - }); - } - - static Stream nativeEnhancerExclusionSelectionArgs() { - return Stream.of(Arguments.of(null, NativeQueryEnhancer.REGEX), // - Arguments.of("", NativeQueryEnhancer.REGEX), // - Arguments.of("auto", NativeQueryEnhancer.REGEX), // - Arguments.of("regex", NativeQueryEnhancer.REGEX), // - Arguments.of("jsqlparser", NativeQueryEnhancer.JSQLPARSER)); - } - - @Test // GH-2989 - @ClassPathExclusions(packages = { "net.sf.jsqlparser.parser" }) - void selectedDefaultImplementationIfJsqlNotAvailable() { - - assertThat(NativeQueryEnhancer.JSQLPARSER_PRESENT).isFalse(); - assertThat(NativeQueryEnhancer.select()).isEqualTo(NativeQueryEnhancer.REGEX); - } - - void withSystemProperty(String property, @Nullable String value, Runnable exeution) { - - String currentValue = System.getProperty(property); - if (value != null) { - System.setProperty(property, value); - } else { - System.clearProperty(property); - } - try { - exeution.run(); - } finally { - if (currentValue != null) { - System.setProperty(property, currentValue); - } else { - System.clearProperty(property); - } - } - - } - - } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerTckTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerTckTests.java index 077d469177..98e19b6cb7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerTckTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerTckTests.java @@ -35,9 +35,8 @@ abstract class QueryEnhancerTckTests { @MethodSource("nativeCountQueries") // GH-2773 void shouldDeriveNativeCountQuery(String query, String expected) { - DeclaredQuery declaredQuery = DeclaredQuery.of(query, true); - QueryEnhancer enhancer = createQueryEnhancer(declaredQuery); - String countQueryFor = enhancer.createCountQueryFor(); + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery(query)); + String countQueryFor = enhancer.createCountQueryFor(null); // lenient cleanup to allow for rendering variance String sanitized = countQueryFor.replaceAll("\r", " ").replaceAll("\n", " ").replaceAll(" {2}", " ") @@ -120,8 +119,7 @@ static Stream nativeCountQueries() { @MethodSource("jpqlCountQueries") void shouldDeriveJpqlCountQuery(String query, String expected) { - DeclaredQuery declaredQuery = DeclaredQuery.of(query, false); - QueryEnhancer enhancer = createQueryEnhancer(declaredQuery); + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.jpqlQuery(query)); String countQueryFor = enhancer.createCountQueryFor(null); assertThat(countQueryFor).isEqualToIgnoringCase(expected); @@ -180,9 +178,8 @@ static Stream jpqlCountQueries() { @MethodSource("nativeQueriesWithVariables") void shouldDeriveNativeCountQueryWithVariable(String query, String expected) { - DeclaredQuery declaredQuery = DeclaredQuery.of(query, true); - QueryEnhancer enhancer = createQueryEnhancer(declaredQuery); - String countQueryFor = enhancer.createCountQueryFor(); + QueryEnhancer enhancer = createQueryEnhancer(DeclaredQuery.nativeQuery(query)); + String countQueryFor = enhancer.createCountQueryFor(null); assertThat(countQueryFor).isEqualToIgnoringCase(expected); } @@ -206,11 +203,11 @@ static Stream nativeQueriesWithVariables() { // DATAJPA-1696 void findProjectionClauseWithIncludedFrom() { - StringQuery query = new StringQuery("select x, frommage, y from t", true); + DefaultEntityQuery query = new TestEntityQuery("select x, frommage, y from t", true); assertThat(createQueryEnhancer(query).getProjection()).isEqualTo("x, frommage, y"); } - abstract QueryEnhancer createQueryEnhancer(DeclaredQuery declaredQuery); + abstract QueryEnhancer createQueryEnhancer(DeclaredQuery query); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java index 3113627c8e..da113f567b 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryEnhancerUnitTests.java @@ -20,7 +20,6 @@ import java.util.Arrays; import java.util.Collections; -import java.util.List; import java.util.Set; import java.util.stream.Stream; @@ -30,9 +29,12 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; + import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.domain.JpaSort; +import org.springframework.data.projection.SpelAwareProxyProjectionFactory; +import org.springframework.data.repository.query.ReturnedType; /** * Unit tests for {@link QueryEnhancer}. @@ -40,6 +42,7 @@ * @author Diego Krupitza * @author Geoffrey Deremetz * @author Krzysztof Krason + * @author Mark Paluch */ class QueryEnhancerUnitTests { @@ -78,9 +81,9 @@ void allowsShortJpaSyntax() { @ParameterizedTest @MethodSource("detectsAliasWithUCorrectlySource") - void detectsAliasWithUCorrectly(DeclaredQuery query, String alias) { + void detectsAliasWithUCorrectly(DefaultEntityQuery query, String alias) { - assumeThat(query.getQueryString()).as("JsqlParser does not support simple JPA syntax.") + assumeThat(query.getQueryString()).as("JsqlParser does not support simple JPA syntax") .doesNotStartWithIgnoringCase("from"); assertThat(getEnhancer(query).detectAlias()).isEqualTo(alias); @@ -89,21 +92,21 @@ void detectsAliasWithUCorrectly(DeclaredQuery query, String alias) { public static Stream detectsAliasWithUCorrectlySource() { return Stream.of( // - Arguments.of(new StringQuery(QUERY, true), "u"), // - Arguments.of(new StringQuery(SIMPLE_QUERY, false), "u"), // - Arguments.of(new StringQuery(COUNT_QUERY, true), "u"), // - Arguments.of(new StringQuery(QUERY_WITH_AS, true), "u"), // - Arguments.of(new StringQuery("SELECT u FROM USER U", false), "U"), // - Arguments.of(new StringQuery("select u from User u", true), "u"), // - Arguments.of(new StringQuery("select u from com.acme.User u", true), "u"), // - Arguments.of(new StringQuery("select u from T05User u", true), "u") // + Arguments.of(new TestEntityQuery(QUERY, true), "u"), // + Arguments.of(new TestEntityQuery(SIMPLE_QUERY, false), "u"), // + Arguments.of(new TestEntityQuery(COUNT_QUERY, true), "u"), // + Arguments.of(new TestEntityQuery(QUERY_WITH_AS, true), "u"), // + Arguments.of(new TestEntityQuery("SELECT u FROM USER U", false), "U"), // + Arguments.of(new TestEntityQuery("select u from User u", true), "u"), // + Arguments.of(new TestEntityQuery("select u from com.acme.User u", true), "u"), // + Arguments.of(new TestEntityQuery("select u from T05User u", true), "u") // ); } @Test void allowsFullyQualifiedEntityNamesInQuery() { - StringQuery query = new StringQuery(FQ_QUERY, true); + DefaultEntityQuery query = new TestEntityQuery(FQ_QUERY, true); assertThat(getEnhancer(query).detectAlias()).isEqualTo("u"); assertCountQuery(FQ_QUERY, "select count(u) from org.acme.domain.User$Foo_Bar u", true); @@ -112,20 +115,18 @@ void allowsFullyQualifiedEntityNamesInQuery() { @Test // DATAJPA-252 void doesNotPrefixOrderReferenceIfOuterJoinAliasDetected() { - StringQuery query = new StringQuery("select p from Person p left join p.address address", true); + DefaultEntityQuery query = new TestEntityQuery("select p from Person p left join p.address address", true); - assertThat(getEnhancer(query).applySorting(Sort.by("address.city"))) + assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("address.city")))) .endsWithIgnoringCase("order by address.city asc"); - assertThat(getEnhancer(query).applySorting(Sort.by("address.city", "lastname"), "p")) - .endsWithIgnoringCase("order by address.city asc, p.lastname asc"); } @Test // DATAJPA-252 void extendsExistingOrderByClausesCorrectly() { - StringQuery query = new StringQuery("select p from Person p order by p.lastname asc", true); + DefaultEntityQuery query = new TestEntityQuery("select p from Person p order by p.lastname asc", true); - assertThat(getEnhancer(query).applySorting(Sort.by("firstname"), "p")) + assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("firstname")))) .endsWithIgnoringCase("order by p.lastname asc, p.firstname asc"); } @@ -134,9 +135,10 @@ void appliesIgnoreCaseOrderingCorrectly() { Sort sort = Sort.by(Sort.Order.by("firstname").ignoreCase()); - StringQuery query = new StringQuery("select p from Person p", true); + DefaultEntityQuery query = new TestEntityQuery("select p from Person p", true); - assertThat(getEnhancer(query).applySorting(sort, "p")).endsWithIgnoringCase("order by lower(p.firstname) asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) + .endsWithIgnoringCase("order by lower(p.firstname) asc"); } @Test // DATAJPA-296 @@ -144,9 +146,9 @@ void appendsIgnoreCaseOrderingCorrectly() { Sort sort = Sort.by(Sort.Order.by("firstname").ignoreCase()); - StringQuery query = new StringQuery("select p from Person p order by p.lastname asc", true); + DefaultEntityQuery query = new TestEntityQuery("select p from Person p order by p.lastname asc", true); - assertThat(getEnhancer(query).applySorting(sort, "p")) + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) .endsWithIgnoringCase("order by p.lastname asc, lower(p.firstname) asc"); } @@ -160,12 +162,12 @@ void projectsCountQueriesForQueriesWithSubSelects() { @Test // DATAJPA-148 void doesNotPrefixSortsIfFunction() { - StringQuery query = new StringQuery("select p from Person p", true); + DefaultEntityQuery query = new TestEntityQuery("select p from Person p", true); Sort sort = Sort.by("sum(foo)"); QueryEnhancer enhancer = getEnhancer(query); - assertThatThrownBy(() -> enhancer.applySorting(sort, "p")) // + assertThatThrownBy(() -> enhancer.rewrite(getRewriteInformation(sort))) // .isInstanceOf(InvalidDataAccessApiUsageException.class); } @@ -173,8 +175,8 @@ void doesNotPrefixSortsIfFunction() { void findsExistingOrderByIndependentOfCase() { Sort sort = Sort.by("lastname"); - StringQuery originalQuery = new StringQuery("select p from Person p ORDER BY p.firstname", true); - String query = getEnhancer(originalQuery).applySorting(sort, "p"); + DefaultEntityQuery originalQuery = new TestEntityQuery("select p from Person p ORDER BY p.firstname", true); + String query = getEnhancer(originalQuery).rewrite(getRewriteInformation(sort)); assertThat(query).endsWithIgnoringCase("ORDER BY p.firstname, p.lastname asc"); } @@ -182,18 +184,17 @@ void findsExistingOrderByIndependentOfCase() { @Test // GH-3263 void preserveSourceQueryWhenAddingSort() { - StringQuery query = new StringQuery("WITH all_projects AS (SELECT * FROM projects) SELECT * FROM all_projects p", - true); + DefaultEntityQuery query = new TestEntityQuery( + "WITH all_projects AS (SELECT * FROM projects) SELECT * FROM all_projects p", true); - assertThat(getEnhancer(query).applySorting(Sort.by("name"), "p")) // - .startsWithIgnoringCase(query.getQueryString()) - .endsWithIgnoringCase("ORDER BY p.name ASC"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("name")))) // + .startsWithIgnoringCase(query.getQueryString()).endsWithIgnoringCase("ORDER BY p.name ASC"); } @Test // GH-2812 void createCountQueryFromDeleteQuery() { - StringQuery query = new StringQuery("delete from some_table where id in :ids", true); + DefaultEntityQuery query = new TestEntityQuery("delete from some_table where id in :ids", true); assertThat(getEnhancer(query).createCountQueryFor("p.lastname")) .isEqualToIgnoringCase("delete from some_table where id in :ids"); @@ -202,7 +203,7 @@ void createCountQueryFromDeleteQuery() { @Test // DATAJPA-456 void createCountQueryFromTheGivenCountProjection() { - StringQuery query = new StringQuery("select p.lastname,p.firstname from Person p", true); + DefaultEntityQuery query = new TestEntityQuery("select p.lastname,p.firstname from Person p", true); assertThat(getEnhancer(query).createCountQueryFor("p.lastname")) .isEqualToIgnoringCase("select count(p.lastname) from Person p"); @@ -211,24 +212,26 @@ void createCountQueryFromTheGivenCountProjection() { @Test // DATAJPA-726 void detectsAliasesInPlainJoins() { - StringQuery query = new StringQuery("select p from Customer c join c.productOrder p where p.delay = true", true); + DefaultEntityQuery query = new TestEntityQuery( + "select p from Customer c join c.productOrder p where p.delay = true", true); Sort sort = Sort.by("p.lineItems"); - assertThat(getEnhancer(query).applySorting(sort, "c")).endsWithIgnoringCase("order by p.lineItems asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) + .endsWithIgnoringCase("order by p.lineItems asc"); } @Test // DATAJPA-736 void supportsNonAsciiCharactersInEntityNames() { - StringQuery query = new StringQuery("select u from Usèr u", true); + DefaultEntityQuery query = new TestEntityQuery("select u from Usèr u", true); - assertThat(getEnhancer(query).createCountQueryFor()).isEqualToIgnoringCase("select count(u) from Usèr u"); + assertThat(getEnhancer(query).createCountQueryFor(null)).isEqualToIgnoringCase("select count(u) from Usèr u"); } @Test // DATAJPA-798 void detectsAliasInQueryContainingLineBreaks() { - StringQuery query = new StringQuery("select \n u \n from \n User \nu", true); + DefaultEntityQuery query = new TestEntityQuery("select \n u \n from \n User \nu", true); assertThat(getEnhancer(query).detectAlias()).isEqualTo("u"); } @@ -237,26 +240,28 @@ void detectsAliasInQueryContainingLineBreaks() { @Test // DATAJPA-815 void doesPrefixPropertyWithNonNative() { - StringQuery query = new StringQuery("from Cat c join Dog d", false); + DefaultEntityQuery query = new TestEntityQuery("from Cat c join Dog d", false); Sort sort = Sort.by("dPropertyStartingWithJoinAlias"); - assertThat(getEnhancer(query).applySorting(sort, "c")).endsWith("order by c.dPropertyStartingWithJoinAlias asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) + .endsWith("order by c.dPropertyStartingWithJoinAlias asc"); } @Test // DATAJPA-815 void doesPrefixPropertyWithNative() { - StringQuery query = new StringQuery("Select * from Cat c join Dog d", true); + DefaultEntityQuery query = new TestEntityQuery("Select * from Cat c join Dog d", true); Sort sort = Sort.by("dPropertyStartingWithJoinAlias"); - assertThat(getEnhancer(query).applySorting(sort, "c")) + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) .endsWithIgnoringCase("order by c.dPropertyStartingWithJoinAlias asc"); } @Test // DATAJPA-938 void detectsConstructorExpressionInDistinctQuery() { - StringQuery query = new StringQuery("select distinct new com.example.Foo(b.name) from Bar b", false); + DefaultEntityQuery query = new TestEntityQuery("select distinct new com.example.Foo(b.name) from Bar b", + false); assertThat(getEnhancer(query).hasConstructorExpression()).isTrue(); } @@ -264,7 +269,7 @@ void detectsConstructorExpressionInDistinctQuery() { @Test // DATAJPA-938 void detectsComplexConstructorExpression() { - StringQuery query = new StringQuery("select new foo.bar.Foo(ip.id, ip.name, sum(lp.amount)) " // + DefaultEntityQuery query = new TestEntityQuery("select new foo.bar.Foo(ip.id, ip.name, sum(lp.amount)) " // + "from Bar lp join lp.investmentProduct ip " // + "where (lp.toDate is null and lp.fromDate <= :now and lp.fromDate is not null) and lp.accountId = :accountId " // @@ -277,7 +282,7 @@ void detectsComplexConstructorExpression() { @Test // DATAJPA-938 void detectsConstructorExpressionWithLineBreaks() { - StringQuery query = new StringQuery("select new foo.bar.FooBar(\na.id) from DtoA a ", false); + DefaultEntityQuery query = new TestEntityQuery("select new foo.bar.FooBar(\na.id) from DtoA a ", false); assertThat(getEnhancer(query).hasConstructorExpression()).isTrue(); } @@ -286,140 +291,138 @@ void detectsConstructorExpressionWithLineBreaks() { @Test // DATAJPA-960 void doesNotQualifySortIfNoAliasDetectedNonNative() { - StringQuery query = new StringQuery("from mytable where ?1 is null", false); + DefaultEntityQuery query = new TestEntityQuery("from mytable where ?1 is null", false); - assertThat(getEnhancer(query).applySorting(Sort.by("firstname"))).endsWith("order by firstname asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("firstname")))) + .endsWith("order by firstname asc"); } @Test // DATAJPA-960 void doesNotQualifySortIfNoAliasDetectedNative() { - StringQuery query = new StringQuery("Select * from mytable where ?1 is null", true); + DefaultEntityQuery query = new TestEntityQuery("Select * from mytable where ?1 is null", true); - assertThat(getEnhancer(query).applySorting(Sort.by("firstname"))).endsWithIgnoringCase("order by firstname asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(Sort.by("firstname")))) + .endsWithIgnoringCase("order by firstname asc"); } @Test // DATAJPA-965, DATAJPA-970 void doesNotAllowWhitespaceInSort() { - StringQuery query = new StringQuery("select p from Person p", true); + DefaultEntityQuery query = new TestEntityQuery("select p from Person p", true); Sort sort = Sort.by("case when foo then bar"); assertThatExceptionOfType(InvalidDataAccessApiUsageException.class) - .isThrownBy(() -> getEnhancer(query).applySorting(sort, "p")); + .isThrownBy(() -> getEnhancer(query).rewrite(getRewriteInformation(sort))); } @Test // DATAJPA-965, DATAJPA-970 void doesNotPrefixUnsafeJpaSortFunctionCalls() { JpaSort sort = JpaSort.unsafe("sum(foo)"); - StringQuery query = new StringQuery("select p from Person p", true); + DefaultEntityQuery query = new TestEntityQuery("select p from Person p", true); - assertThat(getEnhancer(query).applySorting(sort, "p")).endsWithIgnoringCase("order by sum(foo) asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))).endsWithIgnoringCase("order by sum(foo) asc"); } @Test // DATAJPA-965, DATAJPA-970 void doesNotPrefixMultipleAliasedFunctionCalls() { - StringQuery query = new StringQuery("SELECT AVG(m.price) AS avgPrice, SUM(m.stocks) AS sumStocks FROM Magazine m", - true); + DefaultEntityQuery query = new TestEntityQuery( + "SELECT AVG(m.price) AS avgPrice, SUM(m.stocks) AS sumStocks FROM Magazine m", true); Sort sort = Sort.by("avgPrice", "sumStocks"); - assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by avgPrice asc, sumStocks asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) + .endsWithIgnoringCase("order by avgPrice asc, sumStocks asc"); } @Test // DATAJPA-965, DATAJPA-970 void doesNotPrefixSingleAliasedFunctionCalls() { - StringQuery query = new StringQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true); + DefaultEntityQuery query = new TestEntityQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true); Sort sort = Sort.by("avgPrice"); - assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by avgPrice asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))).endsWithIgnoringCase("order by avgPrice asc"); } @Test // DATAJPA-965, DATAJPA-970 void prefixesSingleNonAliasedFunctionCallRelatedSortProperty() { - StringQuery query = new StringQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true); + DefaultEntityQuery query = new TestEntityQuery("SELECT AVG(m.price) AS avgPrice FROM Magazine m", true); Sort sort = Sort.by("someOtherProperty"); - assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by m.someOtherProperty asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) + .endsWithIgnoringCase("order by m.someOtherProperty asc"); } @Test // DATAJPA-965, DATAJPA-970 void prefixesNonAliasedFunctionCallRelatedSortPropertyWhenSelectClauseContainsAliasedFunctionForDifferentProperty() { - StringQuery query = new StringQuery("SELECT m.name, AVG(m.price) AS avgPrice FROM Magazine m", true); + DefaultEntityQuery query = new TestEntityQuery("SELECT m.name, AVG(m.price) AS avgPrice FROM Magazine m", + true); Sort sort = Sort.by("name", "avgPrice"); - assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by m.name asc, avgPrice asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) + .endsWithIgnoringCase("order by m.name asc, avgPrice asc"); } @Test // DATAJPA-965, DATAJPA-970 void doesNotPrefixAliasedFunctionCallNameWithMultipleNumericParameters() { - StringQuery query = new StringQuery("SELECT SUBSTRING(m.name, 2, 5) AS trimmedName FROM Magazine m", true); + DefaultEntityQuery query = new TestEntityQuery( + "SELECT SUBSTRING(m.name, 2, 5) AS trimmedName FROM Magazine m", true); Sort sort = Sort.by("trimmedName"); - assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by trimmedName asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) + .endsWithIgnoringCase("order by trimmedName asc"); } @Test // DATAJPA-965, DATAJPA-970 void doesNotPrefixAliasedFunctionCallNameWithMultipleStringParameters() { - StringQuery query = new StringQuery("SELECT CONCAT(m.name, 'foo') AS extendedName FROM Magazine m", true); + DefaultEntityQuery query = new TestEntityQuery( + "SELECT CONCAT(m.name, 'foo') AS extendedName FROM Magazine m", true); Sort sort = Sort.by("extendedName"); - assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by extendedName asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))) + .endsWithIgnoringCase("order by extendedName asc"); } @Test // DATAJPA-965, DATAJPA-970 void doesNotPrefixAliasedFunctionCallNameWithUnderscores() { - StringQuery query = new StringQuery("SELECT AVG(m.price) AS avg_price FROM Magazine m", true); + DefaultEntityQuery query = new TestEntityQuery("SELECT AVG(m.price) AS avg_price FROM Magazine m", true); Sort sort = Sort.by("avg_price"); - assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by avg_price asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))).endsWithIgnoringCase("order by avg_price asc"); } @Test // DATAJPA-965, DATAJPA-970 void doesNotPrefixAliasedFunctionCallNameWithDots() { - StringQuery query = new StringQuery("SELECT AVG(m.price) AS average FROM Magazine m", false); + DefaultEntityQuery query = new TestEntityQuery("SELECT AVG(m.price) AS average FROM Magazine m", false); Sort sort = Sort.by("avg"); - assertThat(getEnhancer(query).applySorting(sort, "m")).endsWith("order by m.avg asc"); + assertThat(getEnhancer(query).rewrite(getRewriteInformation(sort))).endsWith("order by m.avg asc"); } @Test // DATAJPA-965, DATAJPA-970 void doesNotPrefixAliasedFunctionCallNameWithDotsNativeQuery() { // this is invalid since the '.' character is not allowed. Not in sql nor in JPQL. - assertThatThrownBy(() -> new StringQuery("SELECT AVG(m.price) AS m.avg FROM Magazine m", true)) // + assertThatThrownBy(() -> new TestEntityQuery("SELECT AVG(m.price) AS m.avg FROM Magazine m", true)) // .isInstanceOf(IllegalArgumentException.class); } @Test // DATAJPA-965, DATAJPA-970 void doesNotPrefixAliasedFunctionCallNameWhenQueryStringContainsMultipleWhiteSpaces() { - StringQuery query = new StringQuery("SELECT AVG( m.price ) AS avgPrice FROM Magazine m", true); + DefaultEntityQuery query = new TestEntityQuery( + "SELECT AVG( m.price ) AS avgPrice FROM Magazine m", true); Sort sort = Sort.by("avgPrice"); - assertThat(getEnhancer(query).applySorting(sort, "m")).endsWithIgnoringCase("order by avgPrice asc"); - } - - @Test // DATAJPA-1000 - void discoversCorrectAliasForJoinFetch() { - - String queryString = "SELECT DISTINCT user FROM User user LEFT JOIN user.authorities AS authority"; - Set 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 +436,17 @@ void discoversAliasWithComplexFunction() { assertThat( QueryUtils.getFunctionAliases("select new MyDto(sum(case when myEntity.prop3=0 then 1 else 0 end) as myAlias")) // - .contains("myAlias"); + .contains("myAlias"); } @Test // DATAJPA-1506 void detectsAliasWithGroupAndOrderBy() { - StringQuery queryWithGroupNoAlias = new StringQuery("select * from User group by name", true); - StringQuery queryWithGroupAlias = new StringQuery("select * from User u group by name", true); + DefaultEntityQuery queryWithGroupNoAlias = new TestEntityQuery("select * from User group by name", true); + DefaultEntityQuery queryWithGroupAlias = new TestEntityQuery("select * from User u group by name", true); - StringQuery queryWithOrderNoAlias = new StringQuery("select * from User order by name", true); - StringQuery queryWithOrderAlias = new StringQuery("select * from User u order by name", true); + DefaultEntityQuery queryWithOrderNoAlias = new TestEntityQuery("select * from User order by name", true); + DefaultEntityQuery queryWithOrderAlias = new TestEntityQuery("select * from User u order by name", true); assertThat(getEnhancer(queryWithGroupNoAlias).detectAlias()).isNull(); assertThat(getEnhancer(queryWithOrderNoAlias).detectAlias()).isNull(); @@ -454,12 +457,12 @@ void detectsAliasWithGroupAndOrderBy() { @Test // DATAJPA-1061 void appliesSortCorrectlyForFieldAliases() { - StringQuery query = new StringQuery( + DefaultEntityQuery query = new TestEntityQuery( "SELECT m.price, lower(m.title) AS title, a.name as authorName FROM Magazine m INNER JOIN m.author a", true); Sort sort = Sort.by("authorName"); - String fullQuery = getEnhancer(query).applySorting(sort); + String fullQuery = getEnhancer(query).rewrite(getRewriteInformation(sort)); assertThat(fullQuery).endsWithIgnoringCase("order by authorName asc"); } @@ -467,11 +470,11 @@ void appliesSortCorrectlyForFieldAliases() { @Test // GH-2280 void appliesOrderingCorrectlyForFieldAliasWithIgnoreCase() { - StringQuery query = new StringQuery("SELECT customer.id as id, customer.name as name FROM CustomerEntity customer", - true); + DefaultEntityQuery query = new TestEntityQuery( + "SELECT customer.id as id, customer.name as name FROM CustomerEntity customer", true); Sort sort = Sort.by(Sort.Order.by("name").ignoreCase()); - String fullQuery = getEnhancer(query).applySorting(sort); + String fullQuery = getEnhancer(query).rewrite(getRewriteInformation(sort)); assertThat(fullQuery).isEqualToIgnoringCase( "SELECT customer.id as id, customer.name as name FROM CustomerEntity customer order by lower(name) asc"); @@ -480,12 +483,12 @@ void appliesOrderingCorrectlyForFieldAliasWithIgnoreCase() { @Test // DATAJPA-1061 void appliesSortCorrectlyForFunctionAliases() { - StringQuery query = new StringQuery( + DefaultEntityQuery query = new TestEntityQuery( "SELECT m.price, lower(m.title) AS title, a.name as authorName FROM Magazine m INNER JOIN m.author a", true); Sort sort = Sort.by("title"); - String fullQuery = getEnhancer(query).applySorting(sort); + String fullQuery = getEnhancer(query).rewrite(getRewriteInformation(sort)); assertThat(fullQuery).endsWithIgnoringCase("order by title asc"); } @@ -493,12 +496,12 @@ void appliesSortCorrectlyForFunctionAliases() { @Test // DATAJPA-1061 void appliesSortCorrectlyForSimpleField() { - StringQuery query = new StringQuery( + DefaultEntityQuery query = new TestEntityQuery( "SELECT m.price, lower(m.title) AS title, a.name as authorName FROM Magazine m INNER JOIN m.author a", true); Sort sort = Sort.by("price"); - String fullQuery = getEnhancer(query).applySorting(sort); + String fullQuery = getEnhancer(query).rewrite(getRewriteInformation(sort)); assertThat(fullQuery).endsWithIgnoringCase("order by m.price asc"); } @@ -506,30 +509,34 @@ void appliesSortCorrectlyForSimpleField() { @Test void createCountQuerySupportsLineBreakRightAfterDistinct() { - StringQuery query1 = new StringQuery("select\ndistinct\nuser.age,\n" + // + DefaultEntityQuery query1 = new TestEntityQuery("select\ndistinct\nuser.age,\n" + // "user.name\n" + // "from\nUser\nuser", true); - StringQuery query2 = new StringQuery("select\ndistinct user.age,\n" + // + DefaultEntityQuery query2 = new TestEntityQuery("select\ndistinct user.age,\n" + // "user.name\n" + // "from\nUser\nuser", true); - assertThat(getEnhancer(query1).createCountQueryFor()).isEqualTo(getEnhancer(query2).createCountQueryFor()); + assertThat(getEnhancer(query1).createCountQueryFor(null)).isEqualTo(getEnhancer(query2).createCountQueryFor(null)); } @Test void detectsAliasWithGroupAndOrderByWithLineBreaks() { - StringQuery queryWithGroupAndLineBreak = new StringQuery("select * from User group\nby name", true); - StringQuery queryWithGroupAndLineBreakAndAlias = new StringQuery("select * from User u group\nby name", true); + DefaultEntityQuery queryWithGroupAndLineBreak = new TestEntityQuery("select * from User group\nby name", + true); + DefaultEntityQuery queryWithGroupAndLineBreakAndAlias = new TestEntityQuery( + "select * from User u group\nby name", true); assertThat(getEnhancer(queryWithGroupAndLineBreak).detectAlias()).isNull(); assertThat(getEnhancer(queryWithGroupAndLineBreakAndAlias).detectAlias()).isEqualTo("u"); - StringQuery queryWithOrderAndLineBreak = new StringQuery("select * from User order\nby name", true); - StringQuery queryWithOrderAndLineBreakAndAlias = new StringQuery("select * from User u order\nby name", true); - StringQuery queryWithOrderAndMultipleLineBreakAndAlias = new StringQuery("select * from User\nu\norder \n by name", + DefaultEntityQuery queryWithOrderAndLineBreak = new TestEntityQuery("select * from User order\nby name", true); + DefaultEntityQuery queryWithOrderAndLineBreakAndAlias = new TestEntityQuery( + "select * from User u order\nby name", true); + DefaultEntityQuery queryWithOrderAndMultipleLineBreakAndAlias = new TestEntityQuery( + "select * from User\nu\norder \n by name", true); assertThat(getEnhancer(queryWithOrderAndLineBreak).detectAlias()).isNull(); assertThat(getEnhancer(queryWithOrderAndLineBreakAndAlias).detectAlias()).isEqualTo("u"); @@ -538,7 +545,7 @@ void detectsAliasWithGroupAndOrderByWithLineBreaks() { @ParameterizedTest // DATAJPA-1679 @MethodSource("findProjectionClauseWithDistinctSource") - void findProjectionClauseWithDistinct(DeclaredQuery query, String expected) { + void findProjectionClauseWithDistinct(DefaultEntityQuery query, String expected) { SoftAssertions.assertSoftly(sofly -> sofly.assertThat(getEnhancer(query).getProjection()).isEqualTo(expected)); } @@ -546,10 +553,10 @@ void findProjectionClauseWithDistinct(DeclaredQuery query, String expected) { public static Stream findProjectionClauseWithDistinctSource() { return Stream.of( // - Arguments.of(new StringQuery("select * from x", true), "*"), // - Arguments.of(new StringQuery("select a, b, c from x", true), "a, b, c"), // - Arguments.of(new StringQuery("select distinct a, b, c from x", true), "a, b, c"), // - Arguments.of(new StringQuery("select DISTINCT a, b, c from x", true), "a, b, c") // + Arguments.of(new TestEntityQuery("select * from x", true), "*"), // + Arguments.of(new TestEntityQuery("select a, b, c from x", true), "a, b, c"), // + Arguments.of(new TestEntityQuery("select distinct a, b, c from x", true), "a, b, c"), // + Arguments.of(new TestEntityQuery("select DISTINCT a, b, c from x", true), "a, b, c") // ); } @@ -567,33 +574,17 @@ void findProjectionClauseWithSubselectNative() { // This is a required behavior the testcase in #findProjectionClauseWithSubselect tells why String queryString = "select * from (select x from y)"; - StringQuery query = new StringQuery(queryString, true); + DefaultEntityQuery query = new TestEntityQuery(queryString, true); assertThat(getEnhancer(query).getProjection()).isEqualTo("*"); } - @Disabled - @ParameterizedTest // DATAJPA-252 - @MethodSource("detectsJoinAliasesCorrectlySource") - void detectsJoinAliasesCorrectly(String queryString, List 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,9 +600,10 @@ void correctApplySortOnComplexNestedFunctionQuery() { + " city c\n" // + " ) dd"; - StringQuery nativeQuery = new StringQuery(queryString, true); + DefaultEntityQuery nativeQuery = new TestEntityQuery(queryString, true); QueryEnhancer queryEnhancer = getEnhancer(nativeQuery); - String result = queryEnhancer.applySorting(Sort.by(new Sort.Order(Sort.Direction.ASC, "institutesIds"))); + String result = queryEnhancer + .rewrite(getRewriteInformation(Sort.by(new Sort.Order(Sort.Direction.ASC, "institutesIds")))); assertThat(result).containsIgnoringCase("order by dd.institutesIds"); } @@ -626,22 +618,22 @@ void modifyingQueriesAreDetectedCorrectly() { boolean constructorExpressionNotConsideringQueryType = QueryUtils.hasConstructorExpression(modifyingQuery); String countQueryForNotConsiderQueryType = QueryUtils.createCountQueryFor(modifyingQuery); - StringQuery modiQuery = new StringQuery(modifyingQuery, true); + DefaultEntityQuery modiQuery = new TestEntityQuery(modifyingQuery, true); assertThat(modiQuery.getAlias()).isEqualToIgnoringCase(aliasNotConsideringQueryType); assertThat(modiQuery.getProjection()).isEqualToIgnoringCase(projectionNotConsideringQueryType); assertThat(modiQuery.hasConstructorExpression()).isEqualTo(constructorExpressionNotConsideringQueryType); assertThat(countQueryForNotConsiderQueryType).isEqualToIgnoringCase(modifyingQuery); - assertThat(QueryEnhancerFactory.forQuery(modiQuery).createCountQueryFor()).isEqualToIgnoringCase(modifyingQuery); + assertThat(QueryEnhancer.create(modiQuery).createCountQueryFor(null)).isEqualToIgnoringCase(modifyingQuery); } @ParameterizedTest // GH-2593 @MethodSource("insertStatementIsProcessedSameAsDefaultSource") void insertStatementIsProcessedSameAsDefault(String insertQuery) { - StringQuery stringQuery = new StringQuery(insertQuery, true); - QueryEnhancer queryEnhancer = QueryEnhancerFactory.forQuery(stringQuery); + DefaultEntityQuery stringQuery = new TestEntityQuery(insertQuery, true); + QueryEnhancer queryEnhancer = QueryEnhancer.create(stringQuery); Sort sorting = Sort.by("day").descending(); @@ -657,11 +649,11 @@ void insertStatementIsProcessedSameAsDefault(String insertQuery) { assertThat(stringQuery.hasConstructorExpression()).isFalse(); // access over enhancer - assertThat(queryEnhancer.createCountQueryFor()).isEqualToIgnoringCase(queryUtilsCountQuery); - assertThat(queryEnhancer.applySorting(sorting)).isEqualTo(insertQuery); // cant check with queryutils result since - // query utils appens order by which is not - // supported by sql standard. - assertThat(queryEnhancer.getJoinAliases()).isEqualTo(queryUtilsOuterJoinAlias); + assertThat(queryEnhancer.createCountQueryFor(null)).isEqualToIgnoringCase(queryUtilsCountQuery); + assertThat(queryEnhancer.rewrite(getRewriteInformation(sorting))).isEqualTo(insertQuery); // cant check with + // queryutils result since + // query utils appens order by which is not + // supported by sql standard. assertThat(queryEnhancer.detectAlias()).isEqualToIgnoringCase(queryUtilsDetectAlias); assertThat(queryEnhancer.getProjection()).isEqualToIgnoringCase(queryUtilsProjection); assertThat(queryEnhancer.hasConstructorExpression()).isFalse(); @@ -689,15 +681,20 @@ public static Stream detectsJoinAliasesCorrectlySource() { } private static void assertCountQuery(String originalQuery, String countQuery, boolean nativeQuery) { - assertCountQuery(new StringQuery(originalQuery, nativeQuery), countQuery); + assertCountQuery(new TestEntityQuery(originalQuery, nativeQuery), countQuery); + } + + private static void assertCountQuery(DefaultEntityQuery originalQuery, String countQuery) { + assertThat(getEnhancer(originalQuery).createCountQueryFor(null)).isEqualToIgnoringCase(countQuery); } - private static void assertCountQuery(StringQuery originalQuery, String countQuery) { - assertThat(getEnhancer(originalQuery).createCountQueryFor()).isEqualToIgnoringCase(countQuery); + private static DefaultQueryRewriteInformation getRewriteInformation(Sort sort) { + return new DefaultQueryRewriteInformation(sort, + ReturnedType.of(Object.class, Object.class, new SpelAwareProxyProjectionFactory())); } private static QueryEnhancer getEnhancer(DeclaredQuery query) { - return QueryEnhancerFactory.forQuery(query); + return QueryEnhancer.create(query); } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java index 0b35d49b04..d4fb9a761d 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryParameterSetterFactoryUnitTests.java @@ -18,13 +18,12 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -import java.util.Collections; -import java.util.List; import java.util.stream.Stream; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; + import org.springframework.data.jpa.repository.query.JpaParameters.JpaParameter; import org.springframework.data.jpa.repository.query.ParameterBinding.ParameterOrigin; @@ -48,12 +47,13 @@ void before() { // we have one bindable parameter when(parameters.getBindableParameters().iterator()).thenReturn(Stream.of(mock(JpaParameter.class)).iterator()); - setterFactory = QueryParameterSetterFactory.basic(parameters); + setterFactory = QueryParameterSetterFactory.basic(parameters, true); } @Test // DATAJPA-1058 void noExceptionWhenQueryDoesNotContainNamedParameters() { - setterFactory.create(binding, DeclaredQuery.of("from Employee e", false)); + setterFactory.create(binding, + EntityQuery.create(DeclaredQuery.jpqlQuery("from Employee e"), QueryEnhancerSelector.DEFAULT_SELECTOR)); } @Test // DATAJPA-1058 @@ -63,41 +63,27 @@ void exceptionWhenQueryContainNamedParametersAndMethodParametersAreNotNamed() { assertThatExceptionOfType(IllegalStateException.class) // .isThrownBy(() -> setterFactory.create(binding, - DeclaredQuery.of("from Employee e where e.name = :NamedParameter", false))) // + EntityQuery.create(DeclaredQuery.jpqlQuery("from Employee e where e.name = :NamedParameter"), + QueryEnhancerSelector.DEFAULT_SELECTOR))) // .withMessageContaining("Java 8") // .withMessageContaining("@Param") // .withMessageContaining("-parameters"); } - @Test // DATAJPA-1281 - void exceptionWhenCriteriaQueryContainsInsufficientAmountOfParameters() { - - // no parameter present in the criteria query - List> metadata = Collections.emptyList(); - QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.forCriteriaQuery(parameters, metadata); - - // one argument present in the method signature - when(binding.getRequiredPosition()).thenReturn(1); - when(binding.getOrigin()).thenReturn(ParameterOrigin.ofParameter(null, 1)); - - assertThatExceptionOfType(IllegalArgumentException.class) // - .isThrownBy(() -> setterFactory.create(binding, - DeclaredQuery.of("from Employee e where e.name = :NamedParameter", false))) // - .withMessage("At least 1 parameter(s) provided but only 0 parameter(s) present in query"); - } - @Test // DATAJPA-1281 void exceptionWhenBasicQueryContainsInsufficientAmountOfParameters() { // no parameter present in the criteria query - QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters); + QueryParameterSetterFactory setterFactory = QueryParameterSetterFactory.basic(parameters, false); // one argument present in the method signature when(binding.getRequiredPosition()).thenReturn(1); when(binding.getOrigin()).thenReturn(ParameterOrigin.ofParameter(null, 1)); assertThatExceptionOfType(IllegalArgumentException.class) // - .isThrownBy(() -> setterFactory.create(binding, DeclaredQuery.of("from Employee e where e.name = ?1", false))) // + .isThrownBy(() -> setterFactory.create(binding, + EntityQuery.create(DeclaredQuery.jpqlQuery("from Employee e where e.name = ?1"), + QueryEnhancerSelector.DEFAULT_SELECTOR))) // .withMessage("At least 1 parameter(s) provided but only 0 parameter(s) present in query"); } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryUtilsIntegrationTests.java index 1d4f917a5d..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 @@ -32,6 +32,7 @@ import jakarta.persistence.criteria.From; import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.JoinType; +import jakarta.persistence.criteria.Nulls; import jakarta.persistence.criteria.Path; import jakarta.persistence.criteria.Root; import jakarta.persistence.spi.PersistenceProvider; @@ -44,6 +45,7 @@ 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; @@ -54,11 +56,14 @@ 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") @@ -127,7 +133,6 @@ void prefersFetchOverJoin() { assertThat(expr.getParentPath()).hasFieldOrPropertyWithValue("fetched", true); assertThat(from.getFetches()).hasSize(1); - assertThat(from.getJoins()).hasSize(1); } @Test // DATAJPA-401, DATAJPA-1238 @@ -353,8 +358,8 @@ void toOrdersCanSortByJoinColumn() { assertThat(orders).hasSize(1); } - @Test // GH-3529 - void nullPrecedenceThroughCriteriaApiNotYetSupported() { + @Test // GH-3529, GH-3587 + void queryUtilsConsidersNullPrecedence() { CriteriaBuilder builder = em.getCriteriaBuilder(); CriteriaQuery query = builder.createQuery(User.class); @@ -363,8 +368,10 @@ void nullPrecedenceThroughCriteriaApiNotYetSupported() { Sort sort = Sort.by(Sort.Order.desc("manager").nullsFirst()); - assertThatExceptionOfType(UnsupportedOperationException.class) - .isThrownBy(() -> QueryUtils.toOrders(sort, join, builder)); + List orders = QueryUtils.toOrders(sort, join, builder); + for (jakarta.persistence.criteria.Order order : orders) { + assertThat(order.getNullPrecedence()).isEqualTo(Nulls.FIRST); + } } /** @@ -384,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 647d4dfa2b..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 @@ -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 9f7e2da8ea..5cf31423a9 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeIntegrationTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/QueryWithNullLikeIntegrationTests.java @@ -24,6 +24,7 @@ import javax.sql.DataSource; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -38,7 +39,6 @@ import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.data.repository.query.Param; import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; -import org.springframework.lang.Nullable; import org.springframework.orm.jpa.AbstractEntityManagerFactoryBean; import org.springframework.orm.jpa.JpaTransactionManager; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/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 5d2beb3d9b..6866e7c2d3 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 @@ -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; @@ -38,24 +41,26 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; - 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.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.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.repository.query.ResultProcessor; import org.springframework.data.repository.query.ValueExpressionDelegate; -import org.springframework.data.util.TypeInformation; -import org.springframework.lang.Nullable; /** * Unit test for {@link SimpleJpaQuery}. @@ -75,6 +80,9 @@ @MockitoSettings(strictness = Strictness.LENIENT) class SimpleJpaQueryUnitTests { + private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(), + QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT); + private static final String USER_QUERY = "select u from User u"; private JpaQueryMethod method; @@ -84,7 +92,7 @@ class SimpleJpaQueryUnitTests { @Mock QueryExtractor extractor; @Mock jakarta.persistence.Query query; @Mock TypedQuery typedQuery; - @Mock RepositoryMetadata metadata; + RepositoryMetadata metadata; @Mock ParameterBinder binder; @Mock Metamodel metamodel; @@ -100,12 +108,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 +123,8 @@ void prefersDeclaredCountQueryOverCreatingOne() throws Exception { extractor); when(em.createQuery("foo", Long.class)).thenReturn(typedQuery); - SimpleJpaQuery jpaQuery = new SimpleJpaQuery(method, em, "select u from User u", null, - QueryRewriter.IdentityQueryRewriter.INSTANCE, ValueExpressionDelegate.create()); + SimpleJpaQuery jpaQuery = new SimpleJpaQuery(method, em, method.getDeclaredQuery("select u from User u"), null, + CONFIG); assertThat(jpaQuery.createCountQuery(new JpaParametersParameterAccessor(method.getParameters(), new Object[] {}))) .isEqualTo(typedQuery); @@ -134,8 +138,8 @@ void doesNotApplyPaginationToCountQuery() throws Exception { Method method = UserRepository.class.getMethod("findAllPaged", Pageable.class); JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor); - AbstractJpaQuery jpaQuery = new SimpleJpaQuery(queryMethod, em, "select u from User u", null, - QueryRewriter.IdentityQueryRewriter.INSTANCE, ValueExpressionDelegate.create()); + AbstractJpaQuery jpaQuery = new SimpleJpaQuery(queryMethod, em, + queryMethod.getDeclaredQuery("select u from User u"), null, CONFIG); jpaQuery.createCountQuery( new JpaParametersParameterAccessor(queryMethod.getParameters(), new Object[] { PageRequest.of(1, 10) })); @@ -149,14 +153,12 @@ void discoversNativeQuery() throws Exception { Method method = SampleRepository.class.getMethod("findNativeByLastname", String.class); JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor); - AbstractJpaQuery jpaQuery = JpaQueryFactory.INSTANCE.fromMethodWithQueryString(queryMethod, em, - queryMethod.getAnnotatedQuery(), null, QueryRewriter.IdentityQueryRewriter.INSTANCE, - ValueExpressionDelegate.create()); + AbstractJpaQuery jpaQuery = JpaQueryLookupStrategy.DeclaredQueryLookupStrategy.createStringQuery(queryMethod, em, + queryMethod.getRequiredDeclaredQuery(), null, CONFIG); assertThat(jpaQuery).isInstanceOf(NativeJpaQuery.class); 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" })); @@ -169,14 +171,12 @@ void discoversNativeQueryFromNativeQueryInterface() throws Exception { Method method = SampleRepository.class.getMethod("findByLastnameNativeAnnotation", String.class); JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor); - AbstractJpaQuery jpaQuery = JpaQueryFactory.INSTANCE.fromMethodWithQueryString(queryMethod, em, - queryMethod.getAnnotatedQuery(), null, QueryRewriter.IdentityQueryRewriter.INSTANCE, - ValueExpressionDelegate.create()); + AbstractJpaQuery jpaQuery = JpaQueryLookupStrategy.DeclaredQueryLookupStrategy.createStringQuery(queryMethod, em, + queryMethod.getRequiredDeclaredQuery(), null, CONFIG); assertThat(jpaQuery).isInstanceOf(NativeJpaQuery.class); 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" })); @@ -193,16 +193,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 @@ -239,10 +239,11 @@ void allowsCountQueryUsingParametersNotInOriginalQuery() throws Exception { when(em.createNativeQuery(anyString())).thenReturn(query); AbstractJpaQuery jpaQuery = createJpaQuery( - SampleRepository.class.getMethod("findAllWithBindingsOnlyInCountQuery", String.class, Pageable.class), Optional.empty()); + SampleRepository.class.getMethod("findAllWithBindingsOnlyInCountQuery", String.class, Pageable.class), + Optional.empty()); jpaQuery.doCreateCountQuery(new JpaParametersParameterAccessor(jpaQuery.getQueryMethod().getParameters(), - new Object[]{"data", PageRequest.of(0, 10)})); + new Object[] { "data", PageRequest.of(0, 10) })); ArgumentCaptor queryStringCaptor = ArgumentCaptor.forClass(String.class); verify(em).createQuery(queryStringCaptor.capture(), eq(Long.class)); @@ -263,6 +264,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 { @@ -271,7 +323,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 @@ -282,9 +345,9 @@ void resolvesExpressionInCountQuery() throws Exception { Method method = SampleRepository.class.getMethod("findAllWithExpressionInCountQuery", Pageable.class); JpaQueryMethod queryMethod = new JpaQueryMethod(method, metadata, factory, extractor); - AbstractJpaQuery jpaQuery = new SimpleJpaQuery(queryMethod, em, "select u from User u", - "select count(u.id) from #{#entityName} u", QueryRewriter.IdentityQueryRewriter.INSTANCE, - ValueExpressionDelegate.create()); + AbstractJpaQuery jpaQuery = new SimpleJpaQuery(queryMethod, em, + queryMethod.getDeclaredQuery("select u from User u"), + queryMethod.getDeclaredQuery("select count(u.id) from #{#entityName} u"), CONFIG); jpaQuery.createCountQuery( new JpaParametersParameterAccessor(queryMethod.getParameters(), new Object[] { PageRequest.of(1, 10) })); @@ -296,19 +359,28 @@ private AbstractJpaQuery createJpaQuery(Method method) { return createJpaQuery(method, null); } - private AbstractJpaQuery createJpaQuery(JpaQueryMethod queryMethod, @Nullable String queryString, @Nullable String countQueryString) { + private AbstractJpaQuery createJpaQuery(JpaQueryMethod queryMethod, @Nullable DeclaredQuery query, + @Nullable DeclaredQuery countQzery) { - return JpaQueryFactory.INSTANCE.fromMethodWithQueryString(queryMethod, em, queryString, countQueryString, - QueryRewriter.IdentityQueryRewriter.INSTANCE, ValueExpressionDelegate.create()); + return JpaQueryLookupStrategy.DeclaredQueryLookupStrategy.createStringQuery(queryMethod, em, query, countQzery, + CONFIG); } - private AbstractJpaQuery createJpaQuery(Method method, @Nullable Optional 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); @@ -334,11 +406,28 @@ 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 @@ -347,4 +436,10 @@ interface SampleRepository { } 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 9e0363671d..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 @@ -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/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 77% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/ExpressionBasedStringQueryUnitTests.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TemplatedQueryUnitTests.java index 2b81871822..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 @@ -28,12 +28,12 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; -import org.springframework.data.expression.ValueExpressionParser; import org.springframework.data.jpa.repository.query.ParameterBinding.LikeParameterBinding; +import org.springframework.data.repository.query.ValueExpressionDelegate; import org.springframework.data.repository.query.parser.Part.Type; /** - * Unit tests for {@link ExpressionBasedStringQuery}. + * Unit tests for {@link TemplatedQuery}. * * @author Thomas Darimont * @author Oliver Gierke @@ -45,9 +45,11 @@ */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) -class ExpressionBasedStringQueryUnitTests { +class TemplatedQueryUnitTests { + + private static final JpaQueryConfiguration CONFIG = new JpaQueryConfiguration(QueryRewriterProvider.simple(), + QueryEnhancerSelector.DEFAULT_SELECTOR, ValueExpressionDelegate.create(), EscapeCharacter.DEFAULT); - private static final ValueExpressionParser PARSER = ValueExpressionParser.create(); @Mock JpaEntityMetadata metadata; @BeforeEach @@ -59,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, PARSER, false); + EntityQuery query = jpqlEntityQuery(source); assertThat(query.getQueryString()).isEqualTo("select u from User u where u.firstname like :firstname"); } @Test // DATAJPA-424 void renderAliasInExpressionQueryCorrectly() { - StringQuery query = new ExpressionBasedStringQuery("select u from #{#entityName} u", metadata, PARSER, true); + DefaultEntityQuery query = jpqlEntityQuery("select u from #{#entityName} u"); assertThat(query.getAlias()).isEqualTo("u"); assertThat(query.getQueryString()).isEqualTo("select u from User u"); } + @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, PARSER, false); + + "AND (n.updatedAt >= :#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=:#{#networkRequest.updatedTime.endDateTime})"); assertThat(query.getParameterBindings()).hasSize(8); } @@ -87,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, PARSER, false); + + "AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})"); assertThat(query.getParameterBindings()).hasSize(8); } @@ -100,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, PARSER, true); - - assertThat(query.isNativeQuery()).isFalse(); - } + + "AND (n.updatedAt >= ?#{#networkRequest.updatedTime.startDateTime}) AND (n.updatedAt <=?#{#networkRequest.updatedTime.endDateTime})"); - @Test - void shouldDetectSimpleNativeQueriesWithSpelAsNonNative() { - - StringQuery query = new ExpressionBasedStringQuery("select n from #{#entityName} n", metadata, PARSER, true); - - assertThat(query.isNativeQuery()).isFalse(); + assertThat(query.isNative()).isFalse(); } @Test void shouldDetectSimpleNativeQueriesWithoutSpelAsNative() { - StringQuery query = new ExpressionBasedStringQuery("select u from User u", metadata, PARSER, true); + DefaultEntityQuery query = nativeEntityQuery("select u from User u"); - assertThat(query.isNativeQuery()).isTrue(); + assertThat(query.isNative()).isTrue(); } @Test // GH-3041 void namedExpressionsShouldCreateLikeBindings() { - StringQuery query = new ExpressionBasedStringQuery( - "select u from User u where u.firstname like %:#{foo} or u.firstname like :#{foo}%", metadata, PARSER, - false); + EntityQuery query = jpqlEntityQuery( + "select u from User u where u.firstname like %:#{foo} or u.firstname like :#{foo}%"); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()).isEqualTo( @@ -154,9 +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, PARSER, - false); + EntityQuery query = jpqlEntityQuery( + "select u from User u where u.firstname like %?#{foo} or u.firstname like ?#{foo}%"); assertThat(query.hasParameterBindings()).isTrue(); assertThat(query.getQueryString()) @@ -179,8 +176,7 @@ void indexedExpressionsShouldCreateLikeBindings() { @Test void doesTemplatingWhenEntityNameSpelIsPresent() { - StringQuery query = new ExpressionBasedStringQuery("select #{#entityName + 'Hallo'} from #{#entityName} u", - metadata, PARSER, false); + EntityQuery query = jpqlEntityQuery("select #{#entityName + 'Hallo'} from #{#entityName} u"); assertThat(query.getQueryString()).isEqualTo("select UserHallo from User u"); } @@ -188,8 +184,7 @@ void doesTemplatingWhenEntityNameSpelIsPresent() { @Test void doesNoTemplatingWhenEntityNameSpelIsNotPresent() { - StringQuery query = new ExpressionBasedStringQuery("select #{#entityName + 'Hallo'} from User u", metadata, - PARSER, false); + EntityQuery query = jpqlEntityQuery("select #{#entityName + 'Hallo'} from User u"); assertThat(query.getQueryString()).isEqualTo("select UserHallo from User u"); } @@ -197,9 +192,16 @@ void doesNoTemplatingWhenEntityNameSpelIsNotPresent() { @Test void doesTemplatingWhenEntityNameSpelIsPresentForBindParameter() { - StringQuery query = new ExpressionBasedStringQuery("select u from #{#entityName} u where name = :#{#something}", - metadata, PARSER, false); + EntityQuery query = jpqlEntityQuery("select u from #{#entityName} u where name = :#{#something}"); assertThat(query.getQueryString()).isEqualTo("select u from User u where name = :__$synthetic$__1"); } + + private DefaultEntityQuery nativeEntityQuery(String source) { + return (DefaultEntityQuery) TemplatedQuery.create(DeclaredQuery.nativeQuery(source), metadata, CONFIG); + } + + private DefaultEntityQuery jpqlEntityQuery(String source) { + return (DefaultEntityQuery) TemplatedQuery.create(DeclaredQuery.jpqlQuery(source), metadata, CONFIG); + } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TestEntityQuery.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TestEntityQuery.java new file mode 100644 index 0000000000..25c0848908 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/query/TestEntityQuery.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.repository.query; + +/** + * Test-variant of {@link DefaultEntityQuery} with a simpler constructor. + * + * @author Mark Paluch + */ +class TestEntityQuery extends DefaultEntityQuery { + + /** + * Creates a new {@link DefaultEntityQuery} from the given JPQL query. + * + * @param query must not be {@literal null} or empty. + */ + TestEntityQuery(String query, boolean isNative) { + + super(PreprocessedQuery.parse(isNative ? DeclaredQuery.nativeQuery(query) : DeclaredQuery.jpqlQuery(query)), + QueryEnhancerSelector.DEFAULT_SELECTOR + .select(isNative ? DeclaredQuery.nativeQuery(query) : DeclaredQuery.jpqlQuery(query))); + } +} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/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 c6acc17b33..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-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,21 +13,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/EmployeeRepositoryWithEmbeddedId.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/EmployeeRepositoryWithEmbeddedId.java index ea1bc60f56..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 @@ -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 e3413fed97..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 @@ -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/NameOnlyDto.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java index 13ee35f497..b64c4de2f4 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/NameOnlyDto.java @@ -16,7 +16,7 @@ package org.springframework.data.jpa.repository.sample; // DATAJPA-1334 -class NameOnlyDto { +public class NameOnlyDto { private String firstname; private String lastname; diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/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 fe8e0dd4b6..b02a606673 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RepositoryMethodsWithEntityGraphConfigRepository.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/RepositoryMethodsWithEntityGraphConfigRepository.java @@ -27,9 +27,9 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.querydsl.QuerydslPredicateExecutor; import org.springframework.data.repository.CrudRepository; -import org.springframework.lang.Nullable; import com.querydsl.core.types.Predicate; +import org.jspecify.annotations.Nullable; /** * Custom repository interface that customizes the fetching behavior of querys of well known repository interface diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/sample/UserRepository.java index 88efe091e0..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 @@ -28,6 +28,8 @@ import java.util.Set; import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; + import org.springframework.data.domain.Limit; import org.springframework.data.domain.OffsetScrollPosition; import org.springframework.data.domain.Page; @@ -51,9 +53,10 @@ import org.springframework.data.querydsl.ListQuerydslPredicateExecutor; import org.springframework.data.repository.CrudRepository; import org.springframework.data.repository.query.Param; -import org.springframework.lang.Nullable; import org.springframework.transaction.annotation.Transactional; +import com.querydsl.core.types.Predicate; + /** * Repository interface for {@code User}s. * @@ -296,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. @@ -547,7 +551,7 @@ List findUsersByFirstnameForSpELExpressionWithParameterIndexOnlyWithEntity List findRolesAndFirstnameBy(); - @Query(value = "FROM User u") + @Query(value = "SELECT u FROM User u") List findIdOnly(); // DATAJPA-1172 @@ -613,7 +617,7 @@ Page findAllOrderedBySpecialNameMultipleParams(@Param("name") String name, 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 @@ -643,13 +647,13 @@ Page findAllOrderedBySpecialNameMultipleParams(@Param("name") String name, 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); @@ -724,9 +728,18 @@ List findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter @Query("select u from User u") List findRecordProjection(); - @Query("select u.firstname, LOWER(u.lastname) from User u") + @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); @@ -774,6 +787,15 @@ List findAllAndSortByFunctionResultNamedParameter(@Param("namedParameter 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 { @@ -807,6 +829,12 @@ 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) { } 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 899cbd406d..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 @@ -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/EclipseLinkJpaRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EclipseLinkJpaRepositoryTests.java index 4892d568c1..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 @@ -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/EntityManagerBeanDefinitionRegistrarPostProcessorIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/EntityManagerBeanDefinitionRegistrarPostProcessorIntegrationTests.java index bf2fa3070e..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 @@ -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/JpaEntityInformationSupportUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaEntityInformationSupportUnitTests.java index 6b36813294..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 @@ -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/JpaRepositoryFactoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryUnitTests.java index 4b5ad4cf3e..bdc1a67a94 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFactoryUnitTests.java @@ -15,13 +15,15 @@ */ package org.springframework.data.jpa.repository.support; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.PersistenceUnitUtil; +import jakarta.persistence.metamodel.IdentifiableType; +import jakarta.persistence.metamodel.ManagedType; import jakarta.persistence.metamodel.Metamodel; import java.io.IOException; @@ -35,6 +37,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; + import org.springframework.aop.framework.Advised; import org.springframework.core.OverridingClassLoader; import org.springframework.data.jpa.domain.sample.User; @@ -62,6 +65,7 @@ class JpaRepositoryFactoryUnitTests { private JpaRepositoryFactory factory; @Mock EntityManager entityManager; + @Mock PersistenceUnitUtil persistenceUnitUtil; @Mock Metamodel metamodel; @Mock @SuppressWarnings("rawtypes") JpaEntityInformation entityInformation; @@ -74,6 +78,7 @@ void setUp() { when(entityManager.getEntityManagerFactory()).thenReturn(emf); when(entityManager.getDelegate()).thenReturn(entityManager); when(emf.createEntityManager()).thenReturn(entityManager); + when(emf.getPersistenceUnitUtil()).thenReturn(persistenceUnitUtil); // Setup standard factory configuration factory = new JpaRepositoryFactory(entityManager) { @@ -140,6 +145,9 @@ void handlesCheckedExceptionsCorrectly() { @Test void createsProxyWithCustomBaseClass() { + when(metamodel.managedType(any())) + .thenReturn(mock(ManagedType.class, withSettings().extraInterfaces(IdentifiableType.class))); + JpaRepositoryFactory factory = new CustomGenericJpaRepositoryFactory(entityManager); factory.setQueryLookupStrategyKey(Key.CREATE_IF_NOT_FOUND); UserCustomExtendedRepository repository = factory.getRepository(UserCustomExtendedRepository.class); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFragmentsContributorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/JpaRepositoryFragmentsContributorUnitTests.java new file mode 100644 index 0000000000..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 563c0c7a67..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 @@ -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/OpenJpaMetamodelEntityInformationIntegrationTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaMetamodelEntityInformationIntegrationTests.java deleted file mode 100644 index 5c0a0600dd..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaMetamodelEntityInformationIntegrationTests.java +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2013-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.support; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -/** - * OpenJpa execution for {@link JpaMetamodelEntityInformationIntegrationTests}. - * - * @author Oliver Gierke - * @author Greg Turnquist - */ -@ExtendWith(SpringExtension.class) -@ContextConfiguration({ "classpath:infrastructure.xml", "classpath:openjpa.xml" }) -class OpenJpaMetamodelEntityInformationIntegrationTests extends JpaMetamodelEntityInformationIntegrationTests { - - @Override - String getMetadadataPersistenceUnitName() { - return "metadata_oj"; - } - - /** - * Re-activate test. - */ - @Test - void reactivatedDetectsIdTypeForMappedSuperclass() { - super.detectsIdTypeForMappedSuperclass(); - } - - /** - * Ignore as it fails with weird {@link NoClassDefFoundError}. - */ - @Override - @Disabled - void findsIdClassOnMappedSuperclass() {} - - /** - * Re-activate test for DATAJPA-820. - */ - @Test - @Override - void detectsVersionPropertyOnMappedSuperClass() { - super.detectsVersionPropertyOnMappedSuperClass(); - } -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaProxyIdAccessorTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaProxyIdAccessorTests.java deleted file mode 100644 index 54372525c8..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/OpenJpaProxyIdAccessorTests.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2014-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.support; - -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.ImportResource; -import org.springframework.data.jpa.provider.PersistenceProviderIntegrationTests; -import org.springframework.test.context.ContextConfiguration; - -/** - * @author Oliver Gierke - */ -@ContextConfiguration -class OpenJpaProxyIdAccessorTests extends PersistenceProviderIntegrationTests { - - @Configuration - @ImportResource("classpath:openjpa.xml") - static class Config {} -} diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java index 0eecd481ae..8304430499 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaPredicateExecutorUnitTests.java @@ -213,7 +213,7 @@ void findBySpecificationWithSortByQueryDslOrderSpecifierWithQPageRequestAndQSort QUser user = QUser.user; Page page = predicateExecutor.findAll(user.firstname.isNotNull(), - new QPageRequest(0, 10, new QSort(user.firstname.asc()))); + QPageRequest.of(0, 10, new QSort(user.firstname.asc()))); assertThat(page.getContent()).containsExactly(carter, dave, oliver); } @@ -224,7 +224,7 @@ void findBySpecificationWithSortByQueryDslOrderSpecifierWithQPageRequest() { QUser user = QUser.user; Page page = predicateExecutor.findAll(user.firstname.isNotNull(), - new QPageRequest(0, 10, user.firstname.asc())); + QPageRequest.of(0, 10, user.firstname.asc())); assertThat(page.getContent()).containsExactly(carter, dave, oliver); } @@ -238,7 +238,7 @@ void findBySpecificationWithSortByQueryDslOrderSpecifierForAssociationShouldGene QUser user = QUser.user; Page page = predicateExecutor.findAll(user.firstname.isNotNull(), - new QPageRequest(0, 10, user.manager.firstname.asc())); + QPageRequest.of(0, 10, user.manager.firstname.asc())); assertThat(page.getContent()).containsExactly(carter, dave, oliver); } @@ -551,6 +551,17 @@ void findByFluentPredicateWithComplexPropertyPathsDoesntLoadsRequestedPaths() { assertThat(users).allMatch(u -> u.getRoles().isEmpty()); } + @Test // GH-3877 + void deleteShouldDeleteUsers() { + + long deleted = predicateExecutor.delete(user.dateOfBirth.isNull()); + + assertThat(deleted).isEqualTo(3); + em.flush(); + + assertThat(predicateExecutor.findAll(user.dateOfBirth.isNull())).isEmpty(); + } + private interface UserProjectionInterfaceBased { String getFirstname(); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepositoryTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepositoryTests.java deleted file mode 100644 index ece657841b..0000000000 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/QuerydslJpaRepositoryTests.java +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Copyright 2008-2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.jpa.repository.support; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; - -import java.sql.Date; -import java.time.LocalDate; -import java.util.List; - -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.dao.IncorrectResultSizeDataAccessException; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.domain.Sort.Direction; -import org.springframework.data.domain.Sort.Order; -import org.springframework.data.jpa.domain.sample.Address; -import org.springframework.data.jpa.domain.sample.QUser; -import org.springframework.data.jpa.domain.sample.Role; -import org.springframework.data.jpa.domain.sample.User; -import org.springframework.data.querydsl.QPageRequest; -import org.springframework.data.querydsl.QSort; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.transaction.annotation.Transactional; - -import com.querydsl.core.types.Predicate; -import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.PathBuilder; -import com.querydsl.core.types.dsl.PathBuilderFactory; - -/** - * Integration test for {@link QuerydslJpaRepository}. - * - * @author Oliver Gierke - * @author Thomas Darimont - * @author Mark Paluch - * @author Christoph Strobl - * @author Malte Mauelshagen - * @author Greg Turnquist - * @author Krzysztof Krason - */ -@ExtendWith(SpringExtension.class) -@ContextConfiguration({ "classpath:infrastructure.xml" }) -@Transactional -class QuerydslJpaRepositoryTests { - - @PersistenceContext EntityManager em; - - private QuerydslJpaRepository 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 eb411ddce3..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 @@ -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/SimpleJpaRepositoryUnitTests.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/SimpleJpaRepositoryUnitTests.java index afd9634b44..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 @@ -18,7 +18,6 @@ import static java.util.Collections.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; -import static org.springframework.data.jpa.domain.Specification.*; import jakarta.persistence.EntityGraph; import jakarta.persistence.EntityManager; @@ -31,7 +30,6 @@ 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; @@ -46,6 +44,7 @@ import org.mockito.quality.Strictness; import org.springframework.data.domain.PageRequest; +import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.sample.User; import org.springframework.data.jpa.repository.EntityGraph.EntityGraphType; import org.springframework.data.repository.CrudRepository; @@ -60,6 +59,7 @@ * @author Jens Schauder * @author Greg Turnquist * @author Yanming Zhou + * @author Ariel Morelli Andres */ @ExtendWith(MockitoExtension.class) @MockitoSettings(strictness = Strictness.LENIENT) @@ -84,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); @@ -144,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)); @@ -186,7 +189,6 @@ void doNothingWhenNewInstanceGetsDeleted() { newUser.setId(null); when(em.getEntityManagerFactory()).thenReturn(entityManagerFactory); - when(entityManagerFactory.getPersistenceUnitUtil()).thenReturn(persistenceUnitUtil); repo.delete(newUser); @@ -203,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); @@ -218,7 +219,7 @@ void applyQueryHintsToCountQueriesForSpecificationPageables() { when(query.getResultList()).thenReturn(Arrays.asList(new User(), new User())); - repo.findAll(where(null), PageRequest.of(2, 1)); + repo.findAll(Specification.unrestricted(), PageRequest.of(2, 1)); verify(metadata).getQueryHintsForCount(); } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/StoredProcedureConfigSupport.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TestcontainerConfigSupport.java similarity index 74% rename from spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/StoredProcedureConfigSupport.java rename to spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TestcontainerConfigSupport.java index 998245126b..505ad4b089 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/procedures/StoredProcedureConfigSupport.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/repository/support/TestcontainerConfigSupport.java @@ -1,5 +1,5 @@ /* - * Copyright 2024-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,10 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.springframework.data.jpa.repository.procedures; +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; @@ -29,6 +31,8 @@ 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; @@ -39,12 +43,12 @@ * * @author Mark Paluch */ -class StoredProcedureConfigSupport { +public class TestcontainerConfigSupport { private final Class dialect; private final Resource initScript; - StoredProcedureConfigSupport(Class dialect, Resource initScript) { + protected TestcontainerConfigSupport(Class dialect, Resource initScript) { this.dialect = dialect; this.initScript = initScript; } @@ -67,16 +71,36 @@ AbstractEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) { factoryBean.setDataSource(dataSource); factoryBean.setPersistenceUnitRootLocation("simple-persistence"); factoryBean.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); - factoryBean.setPackagesToScan(this.getClass().getPackage().getName()); + + factoryBean.setManagedTypes(getManagedTypes()); + factoryBean.setPackagesToScan(getPackagesToScan().toArray(new String[0])); + factoryBean.setManagedClassNameFilter(getManagedClassNameFilter()); Properties properties = new Properties(); - properties.setProperty("hibernate.hbm2ddl.auto", "create"); + 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); diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/FixedDate.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/FixedDate.java index a6e2800784..091c3b24f7 100644 --- a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/FixedDate.java +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/FixedDate.java @@ -15,24 +15,25 @@ */ package org.springframework.data.jpa.util; -import java.util.Date; +import java.time.Instant; /** - * Holds a fixed {@link Date} value to use in components that have no direct connection. + * Holds a fixed {@link Instant} value to use in components that have no direct connection. * * @author Thomas Darimont + * @author Christoph Strobl */ public enum FixedDate { INSTANCE; - private Date fixedDate; + private Instant fixedDate; - public void setDate(Date date) { + public void setDate(Instant date) { this.fixedDate = date; } - public Date getDate() { + public Instant getDate() { return fixedDate; } } diff --git a/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java new file mode 100644 index 0000000000..c9c2611e37 --- /dev/null +++ b/spring-data-jpa/src/test/java/org/springframework/data/jpa/util/TestMetaModel.java @@ -0,0 +1,124 @@ +/* + * Copyright 2024-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.jpa.util; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.EntityManagerFactory; +import jakarta.persistence.metamodel.EmbeddableType; +import jakarta.persistence.metamodel.EntityType; +import jakarta.persistence.metamodel.ManagedType; +import jakarta.persistence.metamodel.Metamodel; +import jakarta.persistence.spi.ClassTransformer; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.hibernate.jpa.HibernatePersistenceProvider; +import org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl; +import org.hibernate.jpa.boot.internal.PersistenceUnitInfoDescriptor; +import org.springframework.data.util.Lazy; +import org.springframework.instrument.classloading.SimpleThrowawayClassLoader; +import org.springframework.orm.jpa.persistenceunit.MutablePersistenceUnitInfo; + +/** + * @author Christoph Strobl + */ +public class TestMetaModel implements Metamodel { + + private final String persistenceUnit; + private final Set> 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() { + + MutablePersistenceUnitInfo persistenceUnitInfo = new MutablePersistenceUnitInfo() { + @Override + public ClassLoader getNewTempClassLoader() { + return new SimpleThrowawayClassLoader(this.getClass().getClassLoader()); + } + + @Override + public void addTransformer(ClassTransformer classTransformer) { + // just ingnore it + } + }; + + persistenceUnitInfo.setPersistenceUnitName(persistenceUnit); + this.managedTypes.stream().map(Class::getName).forEach(persistenceUnitInfo::addManagedClassName); + + persistenceUnitInfo.setPersistenceProviderClassName(HibernatePersistenceProvider.class.getName()); + + return new EntityManagerFactoryBuilderImpl(new PersistenceUnitInfoDescriptor(persistenceUnitInfo) { + @Override + public List getManagedClassNames() { + return persistenceUnitInfo.getManagedClassNames(); + } + }, Map.of("hibernate.dialect", "org.hibernate.dialect.H2Dialect")).build(); + } +} diff --git a/spring-data-jpa/src/test/resources/META-INF/orm.xml b/spring-data-jpa/src/test/resources/META-INF/orm.xml index 820a9cced2..65f0ef28fe 100644 --- a/spring-data-jpa/src/test/resources/META-INF/orm.xml +++ b/spring-data-jpa/src/test/resources/META-INF/orm.xml @@ -1,8 +1,8 @@ - + diff --git a/spring-data-jpa/src/test/resources/META-INF/persistence-jmh.xml b/spring-data-jpa/src/test/resources/META-INF/persistence-jmh.xml index 60c6b5c97a..a78eb59468 100644 --- a/spring-data-jpa/src/test/resources/META-INF/persistence-jmh.xml +++ b/spring-data-jpa/src/test/resources/META-INF/persistence-jmh.xml @@ -14,9 +14,10 @@ ~ See the License for the specific language governing permissions and ~ limitations under the License. --> - + org.hibernate.jpa.HibernatePersistenceProvider org.springframework.data.jpa.domain.AbstractPersistable 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/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 04dbefb29a..22b7c06465 100644 --- a/src/main/antora/antora-playbook.yml +++ b/src/main/antora/antora-playbook.yml @@ -17,7 +17,7 @@ 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.4.x ] + branches: [ main ] start_path: src/main/antora asciidoc: attributes: diff --git a/src/main/antora/modules/ROOT/nav.adoc b/src/main/antora/modules/ROOT/nav.adoc index 1e44d61f58..126f33c4af 100644 --- a/src/main/antora/modules/ROOT/nav.adoc +++ b/src/main/antora/modules/ROOT/nav.adoc @@ -14,6 +14,7 @@ ** 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[] @@ -25,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[] 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 990c959441..ac8fcd4a75 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/entity-persistence.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/entity-persistence.adoc @@ -19,7 +19,8 @@ Spring Data JPA offers the following strategies to detect whether an entity is n If the identifier property is `null`, then the entity is assumed to be new. Otherwise, it is assumed to be not new. 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:$$https://docs.spring.io/spring-data/data-commons/docs/current/api/index.html?org/springframework/data/domain/Persistable.html$$[JavaDoc] for details. +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`. 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 c624ec1d30..9f8f7562b1 100644 --- a/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc +++ b/src/main/antora/modules/ROOT/pages/jpa/query-methods.adoc @@ -32,7 +32,7 @@ public interface UserRepository extends Repository { 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: @@ -74,7 +74,7 @@ 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. @@ -83,7 +83,7 @@ This would also yield a `List` result set instead of a `List` resu `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!) @@ -170,89 +170,6 @@ public interface UserRepository extends JpaRepository { ---- ==== -[[jpa.query-methods.query-rewriter]] -=== Applying a QueryRewriter - -Sometimes, no matter how many features you try to apply, it seems impossible to get Spring Data JPA to apply every thing -you'd like to a query before it is sent to the `EntityManager`. - -You have the ability to get your hands on the query, right before it's sent to the `EntityManager` and "rewrite" it. -That is, you can make any alterations at the last moment. -Query rewriting applies to the actual query and, when applicable, to count queries. -Count queries are optimized and therefore, either not necessary or a count is obtained through other means, such as derived from a Hibernate `SelectionQuery`. - - -.Declare a QueryRewriter using `@Query` -==== -[source, java] ----- -public interface MyRepository extends JpaRepository { - - @NativeQuery(value = "select original_user_alias.* from SD_USER original_user_alias", - queryRewriter = MyQueryRewriter.class) - List findByNativeQuery(String param); - - @Query(value = "select original_user_alias from User original_user_alias", - queryRewriter = MyQueryRewriter.class) - List findByNonNativeQuery(String param); -} ----- -==== - -This example shows both a native (pure SQL) rewriter as well as a JPQL query, both leveraging the same `QueryRewriter`. -In this scenario, Spring Data JPA will look for a bean registered in the application context of the corresponding type. - -You can write a query rewriter like this: - -.Example `QueryRewriter` -==== -[source, java] ----- -public class MyQueryRewriter implements QueryRewriter { - - @Override - public String rewrite(String query, Sort sort) { - return query.replaceAll("original_user_alias", "rewritten_user_alias"); - } -} ----- -==== - -You have to ensure your `QueryRewriter` is registered in the application context, whether it's by applying one of Spring Framework's -`@Component`-based annotations, or having it as part of a `@Bean` method inside an `@Configuration` class. - -Another option is to have the repository itself implement the interface. - -.Repository that provides the `QueryRewriter` -==== -[source, java] ----- -public interface MyRepository extends JpaRepository, QueryRewriter { - - @Query(value = "select original_user_alias.* from SD_USER original_user_alias", - nativeQuery = true, - queryRewriter = MyRepository.class) - List findByNativeQuery(String param); - - @Query(value = "select original_user_alias from User original_user_alias", - queryRewriter = MyRepository.class) - List findByNonNativeQuery(String param); - - @Override - default String rewrite(String query, Sort sort) { - return query.replaceAll("original_user_alias", "rewritten_user_alias"); - } -} ----- -==== - -Depending on what you're doing with your `QueryRewriter`, it may be advisable to have more than one, each registered with the -application context. - -NOTE: In a CDI-based environment, Spring Data JPA will search the `BeanManager` for instances of your implementation of -`QueryRewriter`. - - [[jpa.query-methods.at-query.advanced-like]] === Using Advanced `LIKE` Expressions @@ -308,17 +225,6 @@ public interface UserRepository extends JpaRepository { ---- ==== -[TIP] -==== -It is possible to disable usage of `JSqlParser` for parsing native queries although it is available on the classpath by setting `spring.data.jpa.query.native.parser=regex` via the `spring.properties` file or a system property. - -Valid values are (case-insensitive): - -* `auto` (default, automatic selection) -* `regex` (Use the builtin regex-based Query Enhancer) -* `jsqlparser` (Use JSqlParser) -==== - A similar approach also works with named native queries, by adding the `.count` suffix to a copy of your query. You probably need to register a result set mapping for your count query, though. Next to obtaining mapped results, native queries allow you to read the raw `Tuple` from the database by choosing a `Map` container as the method's return type. @@ -344,8 +250,122 @@ interface UserRepository extends JpaRepository { NOTE: String-based Tuple Queries are only supported by Hibernate. Eclipselink supports only Criteria-based Tuple Queries. -[[jpa.query-methods.at-query.projections]] +[[jpa.query-methods.query-introspection-rewriting]] +=== Query Introspection and Rewriting +Spring Data JPA provides a wide range of functionality that can be used to run various flavors of queries. +Specifically, given a declared query, Spring Data JPA can: + +* Introspect a query for its projection and run a tuple query for interface projections +* Use DTO projections if the query uses constructor expressions and rewrite the projection when the query declares the entity alias or just a multi-select of expressions +* Apply dynamic sorting +* Derive a `COUNT` query + +For this purpose, we ship with Query Parsers specific to HQL (Hibernate) and EQL (EclipseLink) dialects as these dialects are well-defined. +SQL on the other hand allows for quite some variance across dialects. +Because of this, there is no way Spring Data will ever be able to support all levels of query complexity. +We are not general purpose SQL parser library but one to increase developer productivity through making query execution simpler. +Our built-in SQL query enhancer supports only simple queries for introspection `COUNT` query derivation. +A more complex query will require either the usage of link:https://github.com/JSQLParser/JSqlParser[JSqlParser] or that you provide a `COUNT` query through `@Query(countQuery=…)`. +If JSqlParser is on the class path, Spring Data JPA will use it for native queries. + +For a fine-grained control over selection, you can configure javadoc:org.springframework.data.jpa.repository.query.QueryEnhancerSelector[] using `@EnableJpaRepositories`: + +.Spring Data JPA repositories using JavaConfig +==== +[source,java] +---- +@Configuration +@EnableJpaRepositories(queryEnhancerSelector = MyQueryEnhancerSelector.class) +class ApplicationConfig { + // … +} +---- +==== + +`QueryEnhancerSelector` is a strategy interface intended to select a javadoc:org.springframework.data.jpa.repository.query.QueryEnhancer[] based on a specific query. +You can also provide your own `QueryEnhancer` implementation if you want. + +[[jpa.query-methods.query-rewriter]] +=== Applying a QueryRewriter + +Sometimes, no matter how many features you try to apply, it seems impossible to get Spring Data JPA to apply every thing you'd like to a query before it is sent to the `EntityManager`. + +You have the ability to get your hands on the query, right before it's sent to the `EntityManager` and "rewrite" it. +That is, you can make any alterations at the last moment. +Query rewriting applies to the actual query and, when applicable, to count queries. +Count queries are optimized and therefore, either not necessary or a count is obtained through other means, such as derived from a Hibernate `SelectionQuery` if there is an enclosing transaction. + +.Declare a QueryRewriter using `@Query` +==== +[source,java] +---- +public interface MyRepository extends JpaRepository { + + @NativeQuery(value = "select original_user_alias.* from SD_USER original_user_alias", + queryRewriter = MyQueryRewriter.class) + List findByNativeQuery(String param); + + @Query(value = "select original_user_alias from User original_user_alias", + queryRewriter = MyQueryRewriter.class) + List findByNonNativeQuery(String param); +} +---- +==== + +This example shows both a native (pure SQL) rewriter as well as a JPQL query, both leveraging the same `QueryRewriter`. +In this scenario, Spring Data JPA will look for a bean registered in the application context of the corresponding type. + +You can write a query rewriter like this: + +.Example `QueryRewriter` +==== +[source,java] +---- +public class MyQueryRewriter implements QueryRewriter { + + @Override + public String rewrite(String query, Sort sort) { + return query.replaceAll("original_user_alias", "rewritten_user_alias"); + } +} +---- +==== + +You have to ensure your `QueryRewriter` is registered in the application context, whether it's by applying one of Spring Framework's +`@Component`-based annotations, or having it as part of a `@Bean` method inside an `@Configuration` class. + +Another option is to have the repository itself implement the interface. + +.Repository that provides the `QueryRewriter` +==== +[source,java] +---- +public interface MyRepository extends JpaRepository, QueryRewriter { + + @Query(value = "select original_user_alias.* from SD_USER original_user_alias", + nativeQuery = true, + queryRewriter = MyRepository.class) + List findByNativeQuery(String param); + + @Query(value = "select original_user_alias from User original_user_alias", + queryRewriter = MyRepository.class) + List findByNonNativeQuery(String param); + + @Override + default String rewrite(String query, Sort sort) { + return query.replaceAll("original_user_alias", "rewritten_user_alias"); + } +} +---- +==== + +Depending on what you're doing with your `QueryRewriter`, it may be advisable to have more than one, each registered with the application context. + +NOTE: In a CDI-based environment, Spring Data JPA will search the `BeanManager` for instances of your implementation of +`QueryRewriter`. + +[[jpa.query-methods.at-query.projections]] [[jpa.query-methods.sorting]] == Using Sort @@ -383,6 +403,17 @@ Throws Exception. <4> Valid `Sort` expression pointing to aliased function. ==== +=== JpaSort.unsafe(…) limitations + +`JpaSort.unsafe(…)` operates in two modes: + +* When used with derived Queries or String-based Queries, the order string is appended to the query. +* When used with Query by Example or Specifications (that use `CriteriaQuery`), order expressions are parsed and added to the `CriteriaQuery` as expressions. +Query expressions can contain function calls, various clauses (such as `CASE WHEN`, arithmetic expressions) or property paths. +Order translation does not support subquery expressions, `TREAT` and `CAST`.` + +[[jpa.query-methods.paging]] + [[jpa.query-methods.scroll]] == Scrolling Large Query Results @@ -394,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]. @@ -429,14 +460,14 @@ NOTE: The method parameters are switched according to their order in the defined NOTE: As of version 4, Spring fully supports Java 8’s parameter name discovery based on the `-parameters` compiler flag. By using this flag in your build as an alternative to debug information, you can omit the `@Param` annotation for named parameters. [[jpa.query.spel-expressions]] -== Using Expressions +== Templated Queries and Expressions We support the usage of restricted expressions in manually defined queries that are defined with `@Query`. Upon the query being run, these expressions are evaluated against a predefined set of variables. NOTE: If you are not familiar with Value Expressions, please refer to xref:jpa/value-expressions.adoc[] to learn about SpEL Expressions and Property Placeholders. -Spring Data JPA supports a variable called `entityName`. +Spring Data JPA supports a template variable called `entityName`. Its usage is `select x from #{#entityName} x`. It inserts the `entityName` of the domain type associated with the given repository. The `entityName` is resolved as follows: diff --git a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc index 0eb4682ff2..a9df80376a 100644 --- a/src/main/antora/modules/ROOT/pages/repositories/projections.adoc +++ b/src/main/antora/modules/ROOT/pages/repositories/projections.adoc @@ -32,9 +32,12 @@ Support for string-based queries covers both, JPQL queries(`@Query`) and native ==== JPQL Queries -When using <> with JPQL, you must use *constructor expressions* in your JPQL query, e.g. `SELECT new com.example.NamesOnly(u.firstname, u.lastname) from User u`. +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[] +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 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 eedc4999e3..b0a1b58fdb 100644 --- a/src/main/antora/resources/antora-resources/antora.yml +++ b/src/main/antora/resources/antora-resources/antora.yml @@ -3,20 +3,21 @@ prerelease: ${antora-component.prerelease} asciidoc: attributes: - version: ${project.version} - copyright-year: ${current.year} - 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} + springhateoasversion: '${spring-hateoas}' hibernatejavadocurl: https://docs.jboss.org/hibernate/orm/6.6/javadocs/ - releasetrainversion: ${releasetrain} + releasetrainversion: '${releasetrain}' store: Jpa