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 new file mode 100644 index 0000000000..4c8108d353 --- /dev/null +++ b/.github/workflows/project.yml @@ -0,0 +1,45 @@ +# GitHub Actions to automate GitHub issues for Spring Data Project Management + +name: Spring Data GitHub Issues + +on: + issues: + types: [opened, edited, reopened] + issue_comment: + types: [created] + pull_request_target: + types: [opened, edited, reopened] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + Inbox: + runs-on: ubuntu-latest + if: github.repository_owner == 'spring-projects' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request == null && !contains(join(github.event.issue.labels.*.name, ', '), 'dependency-upgrade') && !contains(github.event.issue.title, 'Release ') + steps: + - name: Create or Update Issue Card + uses: actions/add-to-project@v1.0.2 + with: + project-url: https://github.com/orgs/spring-projects/projects/25 + github-token: ${{ secrets.GH_ISSUES_TOKEN_SPRING_DATA }} + Pull-Request: + runs-on: ubuntu-latest + if: github.repository_owner == 'spring-projects' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request != null + steps: + - name: Create or Update Pull Request Card + uses: actions/add-to-project@v1.0.2 + with: + project-url: https://github.com/orgs/spring-projects/projects/25 + github-token: ${{ secrets.GH_ISSUES_TOKEN_SPRING_DATA }} + Feedback-Provided: + runs-on: ubuntu-latest + if: github.repository_owner == 'spring-projects' && github.event_name == 'issue_comment' && github.event.action == 'created' && github.actor != 'spring-projects-issues' && github.event.pull_request == null && github.event.issue.state == 'open' && contains(toJSON(github.event.issue.labels), 'waiting-for-feedback') + steps: + - name: Update Project Card + uses: actions/add-to-project@v1.0.2 + with: + project-url: https://github.com/orgs/spring-projects/projects/25 + github-token: ${{ secrets.GH_ISSUES_TOKEN_SPRING_DATA }} diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties index e075a74d86..9609ca640d 100644 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,3 +1,3 @@ -#Thu Nov 07 09:47:28 CET 2024 +#Thu Jul 17 14:00:55 CEST 2025 wrapperUrl=https\://repo.maven.apache.org/maven2/io/takari/maven-wrapper/0.5.6/maven-wrapper-0.5.6.jar -distributionUrl=https\://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip +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 1d2500ed1e..9df3e280d5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -9,7 +9,7 @@ pipeline { triggers { pollSCM 'H/10 * * * *' - upstream(upstreamProjects: "spring-data-commons/main", threshold: hudson.model.Result.SUCCESS) + upstream(upstreamProjects: "spring-data-commons/3.5.x", threshold: hudson.model.Result.SUCCESS) } options { diff --git a/SECURITY.adoc b/SECURITY.adoc index 2694f228b5..654bfbea58 100644 --- a/SECURITY.adoc +++ b/SECURITY.adoc @@ -1,9 +1,15 @@ -# Security Policy += Security Policy -## Supported Versions +== Reporting a Vulnerability -Please see the https://spring.io/projects/spring-data-elasticsearch[Spring Data Elasticsearch] project page for supported versions. +Please, https://github.com/spring-projects/security-advisories/security/advisories/new[open a draft security advisory] if you need to disclose and discuss a security issue in private with the Spring Data team. +Note that we only accept reports against https://spring.io/projects/spring-data#support[supported versions]. -## Reporting a Vulnerability +For more details, check out our https://spring.io/security-policy[security policy]. -Please don't raise security vulnerabilities here. Head over to https://pivotal.io/security to learn how to disclose them responsibly. +== JAR signing + +Spring Data JARs released on Maven Central are signed. +You'll find more information about the key here: https://spring.io/GPG-KEY-spring.txt + +Versions released prior to 2023 may be signed with a different key. diff --git a/pom.xml b/pom.xml index 51f60ff7b8..f3084eebb7 100644 --- a/pom.xml +++ b/pom.xml @@ -1,16 +1,16 @@ - + 4.0.0 org.springframework.data spring-data-elasticsearch - 5.5.0 + 5.5.7-SNAPSHOT org.springframework.data.build spring-data-parent - 3.5.0 + 3.5.7-SNAPSHOT Spring Data Elasticsearch @@ -18,10 +18,10 @@ https://github.com/spring-projects/spring-data-elasticsearch - 3.5.0 + 3.5.7-SNAPSHOT - 8.18.1 + 8.18.8 0.19.0 2.23.1 @@ -41,6 +41,15 @@ + + sothawo + Peter-Josef Meisch + pj.meisch at sothawo.com + + Project Lead + + +1 + biomedcentral BioMed Central Development Team @@ -77,11 +86,6 @@ - - Bamboo - https://build.spring.io/browse/SPRINGDATAES - - GitHub https://github.com/spring-projects/spring-data-elasticsearch/issues @@ -462,8 +466,20 @@ - - + + spring-snapshot + https://repo.spring.io/snapshot + + true + + + false + + + + spring-milestone + https://repo.spring.io/milestone + diff --git a/src/main/antora/modules/ROOT/pages/elasticsearch/repositories/elasticsearch-repository-queries.adoc b/src/main/antora/modules/ROOT/pages/elasticsearch/repositories/elasticsearch-repository-queries.adoc index b22e17522d..d22fa21fdb 100644 --- a/src/main/antora/modules/ROOT/pages/elasticsearch/repositories/elasticsearch-repository-queries.adoc +++ b/src/main/antora/modules/ROOT/pages/elasticsearch/repositories/elasticsearch-repository-queries.adoc @@ -376,7 +376,7 @@ So calling the method with a `List` of `["id1", "id2", "id3"]` would produce the .Declare query on the method using the `@Query` annotation with SpEL expression. ==== -https://docs.spring.io/spring-framework/reference/core/expressions.html[SpEL expression] is also supported when defining query in `@Query`. +{spring-framework-docs}/core/expressions.html[SpEL expression] is also supported when defining query in `@Query`. [source,java] ---- @@ -453,7 +453,7 @@ We can pass `new QueryParameter("John")` as the parameter now, and it will produ .accessing bean property. ==== -https://docs.spring.io/spring-framework/reference/core/expressions/language-ref/bean-references.html[Bean property] is also supported to access. +{spring-framework-docs}/core/expressions/language-ref/bean-references.html[Bean property] is also supported to access. Given that there is a bean named `queryParameter` of type `QueryParameter`, we can access the bean with symbol `@` rather than `#`, and there is no need to declare a parameter of type `QueryParameter` in the query method: [source,java] @@ -523,7 +523,7 @@ A collection of `names` like `List.of("name1", "name2")` will produce the follow .access property in the `Collection` param. ==== -https://docs.spring.io/spring-framework/reference/core/expressions/language-ref/collection-projection.html[SpEL Collection Projection] is convenient to use when values in the `Collection` parameter is not plain `String`: +{spring-framework-docs}/core/expressions/language-ref/collection-projection.html[SpEL Collection Projection] is convenient to use when values in the `Collection` parameter is not plain `String`: [source,java] ---- diff --git a/src/main/antora/modules/ROOT/pages/elasticsearch/versions.adoc b/src/main/antora/modules/ROOT/pages/elasticsearch/versions.adoc index 72ad88a0ee..e24c86ee49 100644 --- a/src/main/antora/modules/ROOT/pages/elasticsearch/versions.adoc +++ b/src/main/antora/modules/ROOT/pages/elasticsearch/versions.adoc @@ -6,7 +6,7 @@ The following table shows the Elasticsearch and Spring versions that are used by [cols="^,^,^,^",options="header"] |=== | Spring Data Release Train | Spring Data Elasticsearch | Elasticsearch | Spring Framework -| 2025.0 | 5.5.x | 8.18.1 | 6.2.x +| 2025.0 | 5.5.x | 8.18.8 | 6.2.x | 2024.1 | 5.4.x | 8.15.5 | 6.1.x | 2024.0 | 5.3.xfootnote:oom[Out of maintenance] | 8.13.4 | 6.1.x | 2023.1 (Vaughan) | 5.2.xfootnote:oom[] | 8.11.1 | 6.1.x diff --git a/src/main/antora/resources/antora-resources/antora.yml b/src/main/antora/resources/antora-resources/antora.yml index 0f79f52be7..abd38734ad 100644 --- a/src/main/antora/resources/antora-resources/antora.yml +++ b/src/main/antora/resources/antora-resources/antora.yml @@ -3,19 +3,20 @@ prerelease: ${antora-component.prerelease} asciidoc: attributes: - copyright-year: ${current.year} - version: ${project.version} - springversionshort: ${spring.short} - springversion: ${spring} attribute-missing: 'warn' - commons: ${springdata.commons.docs} + chomp: 'all' + version: '${project.version}' + copyright-year: '${current.year}' + springversionshort: '${spring.short}' + springversion: '${spring}' + commons: '${springdata.commons.docs}' include-xml-namespaces: false - spring-data-commons-docs-url: https://docs.spring.io/spring-data/commons/reference - spring-data-commons-javadoc-base: https://docs.spring.io/spring-data/commons/docs/${springdata.commons}/api/ - springdocsurl: https://docs.spring.io/spring-framework/reference/{springversionshort} - springjavadocurl: https://docs.spring.io/spring-framework/docs/${spring}/javadoc-api + spring-data-commons-docs-url: '${documentation.baseurl}/spring-data/commons/reference/${springdata.commons.short}' + spring-data-commons-javadoc-base: '{spring-data-commons-docs-url}/api/java' + springdocsurl: '${documentation.baseurl}/spring-framework/reference/{springversionshort}' spring-framework-docs: '{springdocsurl}' + springjavadocurl: '${documentation.spring-javadoc-url}' spring-framework-javadoc: '{springjavadocurl}' - springhateoasversion: ${spring-hateoas} - releasetrainversion: ${releasetrain} + springhateoasversion: '${spring-hateoas}' + releasetrainversion: '${releasetrain}' store: Elasticsearch diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java index 658b2caee8..b6f8e77b03 100644 --- a/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/RequestConverter.java @@ -120,9 +120,6 @@ class RequestConverter extends AbstractQueryProcessor { private static final Log LOGGER = LogFactory.getLog(RequestConverter.class); - // the default max result window size of Elasticsearch - public static final Integer INDEX_MAX_RESULT_WINDOW = 10_000; - protected final JsonpMapper jsonpMapper; protected final ElasticsearchConverter elasticsearchConverter; @@ -1039,18 +1036,7 @@ public DeleteByQueryRequest documentDeleteByQueryRequest(DeleteQuery query, @Nul List sortOptions = getSortOptions(query.getSort(), persistentEntity); if (!sortOptions.isEmpty()) { - dqb.sort( - sortOptions.stream() - .map(sortOption -> { - String order = "asc"; - var sortField = sortOption.field(); - if (sortField.order() != null) { - order = sortField.order().jsonValue(); - } - - return sortField.field() + ':' + order; - }) - .collect(Collectors.toList())); + dqb.sort(sortOptions); } } if (query.getRefresh() != null) { @@ -1295,15 +1281,8 @@ public MsearchRequest searchMsearchRequest( .timeout(timeStringMs(query.getTimeout())) // ; - var offset = query.getPageable().isPaged() ? query.getPageable().getOffset() : 0; - var pageSize = query.getPageable().isPaged() ? query.getPageable().getPageSize() - : INDEX_MAX_RESULT_WINDOW; - // if we have both a page size and a max results, we take the min, this is necessary for - // searchForStream to work correctly (#3098) as there the page size defines what is - // returned in a single request, and the max result determines the total number of - // documents returned - var size = query.isLimiting() ? Math.min(pageSize, query.getMaxResults()) : pageSize; - bb.from((int) offset).size(size); + bb.from((int) (query.getPageable().isPaged() ? query.getPageable().getOffset() : 0)) + .size(query.getRequestSize()); if (!isEmpty(query.getFields())) { bb.fields(fb -> { @@ -1473,14 +1452,8 @@ private void prepareSearchRequest(Query query, @Nullable String routing, @Nu builder.seqNoPrimaryTerm(true); } - var offset = query.getPageable().isPaged() ? query.getPageable().getOffset() : 0; - var pageSize = query.getPageable().isPaged() ? query.getPageable().getPageSize() : INDEX_MAX_RESULT_WINDOW; - // if we have both a page size and a max results, we take the min, this is necessary for - // searchForStream to work correctly (#3098) as there the page size defines what is - // returned in a single request, and the max result determines the total number of - // documents returned - var size = query.isLimiting() ? Math.min(pageSize, query.getMaxResults()) : pageSize; - builder.from((int) offset).size(size); + builder.from((int) (query.getPageable().isPaged() ? query.getPageable().getOffset() : 0)) + .size(query.getRequestSize()); if (!isEmpty(query.getFields())) { var fieldAndFormats = query.getFields().stream().map(field -> FieldAndFormat.of(b -> b.field(field))).toList(); diff --git a/src/main/java/org/springframework/data/elasticsearch/client/elc/TypeUtils.java b/src/main/java/org/springframework/data/elasticsearch/client/elc/TypeUtils.java index 43747f3a7a..5ff3272977 100644 --- a/src/main/java/org/springframework/data/elasticsearch/client/elc/TypeUtils.java +++ b/src/main/java/org/springframework/data/elasticsearch/client/elc/TypeUtils.java @@ -436,7 +436,7 @@ static Integer waitForActiveShardsCount(@Nullable String value) { // values taken from the RHLC implementation if (value == null) { return -2; - } else if ("all".equals(value.toUpperCase())) { + } else if ("all".equals(value.toLowerCase())) { return -1; } else { try { diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/BaseQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/BaseQuery.java index a174511c44..71b16467e2 100755 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/BaseQuery.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/BaseQuery.java @@ -27,6 +27,7 @@ import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.lang.Nullable; @@ -47,10 +48,15 @@ */ public class BaseQuery implements Query { + public static final int INDEX_MAX_RESULT_WINDOW = 10_000; + private static final int DEFAULT_REACTIVE_BATCH_SIZE = 500; + // the instance to mark the query pageable initial status, needed to distinguish between the initial + // value and a user-set unpaged value; values don't matter, the RequestConverter compares to the isntance. + private static final Pageable UNSET_PAGE = PageRequest.of(0, 1); @Nullable protected Sort sort; - protected Pageable pageable = DEFAULT_PAGE; + protected Pageable pageable = UNSET_PAGE; protected List fields = new ArrayList<>(); @Nullable protected List storedFields; @Nullable protected SourceFilter sourceFilter; @@ -78,7 +84,7 @@ public class BaseQuery implements Query { private boolean queryIsUpdatedByConverter = false; @Nullable private Integer reactiveBatchSize = null; @Nullable private Boolean allowNoIndices = null; - private EnumSet expandWildcards; + private EnumSet expandWildcards = EnumSet.noneOf(IndicesOptions.WildcardStates.class); private List docValueFields = new ArrayList<>(); private List scriptedFields = new ArrayList<>(); @@ -87,7 +93,7 @@ public BaseQuery() {} public > BaseQuery(BaseQueryBuilder builder) { this.sort = builder.getSort(); // do a setPageable after setting the sort, because the pageable may contain an additional sort - this.setPageable(builder.getPageable() != null ? builder.getPageable() : DEFAULT_PAGE); + this.setPageable(builder.getPageable() != null ? builder.getPageable() : UNSET_PAGE); this.fields = builder.getFields(); this.storedFields = builder.getStoredFields(); this.sourceFilter = builder.getSourceFilter(); @@ -203,7 +209,7 @@ public SourceFilter getSourceFilter() { @Override @SuppressWarnings("unchecked") public final T addSort(@Nullable Sort sort) { - if (sort == null) { + if (sort == null || sort.isUnsorted()) { return (T) this; } @@ -561,4 +567,52 @@ public void addScriptedField(ScriptedField scriptedField) { public List getScriptedFields() { return scriptedFields; } + + @Override + public Integer getRequestSize() { + + var pageable = getPageable(); + Integer requestSize = null; + + if (pageable.isPaged() && pageable != UNSET_PAGE) { + // pagesize defined by the user + if (!isLimiting()) { + // no maxResults + requestSize = pageable.getPageSize(); + } else { + // if we have both a page size and a max results, we take the min, this is necessary for + // searchForStream to work correctly (#3098) as there the page size defines what is + // returned in a single request, and the max result determines the total number of + // documents returned. + requestSize = Math.min(pageable.getPageSize(), getMaxResults()); + } + } else if (pageable == UNSET_PAGE) { + // no user defined pageable + if (isLimiting()) { + // maxResults + requestSize = getMaxResults(); + } else { + requestSize = DEFAULT_PAGE_SIZE; + } + } else { + // explicitly set unpaged + if (!isLimiting()) { + // no maxResults + requestSize = INDEX_MAX_RESULT_WINDOW; + } else { + // if we have both a implicit page size and a max results, we take the min, this is necessary for + // searchForStream to work correctly (#3098) as there the page size defines what is + // returned in a single request, and the max result determines the total number of + // documents returned. + requestSize = Math.min(INDEX_MAX_RESULT_WINDOW, getMaxResults()); + } + } + + if (requestSize == null) { + // this should not happen + requestSize = DEFAULT_PAGE_SIZE; + } + + return requestSize; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java b/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java index d398d7ee1f..0a46dcd793 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/Criteria.java @@ -61,12 +61,16 @@ public class Criteria { private float boost = Float.NaN; private boolean negating = false; - private final CriteriaChain criteriaChain = new CriteriaChain(); - private final Set queryCriteriaEntries = new LinkedHashSet<>(); - private final Set filterCriteriaEntries = new LinkedHashSet<>(); - private final Set subCriteria = new LinkedHashSet<>(); + // we cash this and recalculate when properties used in equals change + // see https://github.com/spring-projects/spring-data-elasticsearch/issues/3083 + private int hashCode; - // region criteria creation + private final CriteriaChain criteriaChain = new CriteriaChain(); + private final Set queryCriteriaEntries = new LinkedHashSet<>(); + private final Set filterCriteriaEntries = new LinkedHashSet<>(); + private final Set subCriteria = new LinkedHashSet<>(); + + // region criteria creation /** * @return factory method to create an and-Criteria that is not bound to a field @@ -84,7 +88,9 @@ public static Criteria or() { return new OrCriteria(); } - public Criteria() {} + public Criteria() { + recalculateHashCode(); + } /** * Creates a new Criteria with provided field name @@ -107,6 +113,7 @@ public Criteria(Field field) { this.field = field; this.criteriaChain.add(this); + recalculateHashCode(); } /** @@ -136,6 +143,7 @@ protected Criteria(List criteriaChain, Field field) { this.field = field; this.criteriaChain.addAll(criteriaChain); this.criteriaChain.add(this); + recalculateHashCode(); } /** @@ -189,6 +197,7 @@ public List getCriteriaChain() { */ public Criteria not() { this.negating = true; + recalculateHashCode(); return this; } @@ -207,6 +216,7 @@ public Criteria boost(float boost) { Assert.isTrue(boost >= 0, "boost must not be negative"); this.boost = boost; + recalculateHashCode(); return this; } @@ -223,7 +233,7 @@ public boolean isOr() { } /** - * @return the set ob subCriteria + * @return the set of subCriteria * @since 4.1 */ public Set getSubCriteria() { @@ -264,6 +274,7 @@ public Criteria and(Criteria criteria) { Assert.notNull(criteria, "Cannot chain 'null' criteria."); this.criteriaChain.add(criteria); + recalculateHashCode(); return this; } @@ -278,6 +289,7 @@ public Criteria and(Criteria... criterias) { Assert.notNull(criterias, "Cannot chain 'null' criterias."); this.criteriaChain.addAll(Arrays.asList(criterias)); + recalculateHashCode(); return this; } @@ -320,6 +332,7 @@ public Criteria or(Criteria criteria) { orCriteria.subCriteria.addAll(criteria.subCriteria); orCriteria.boost = criteria.boost; orCriteria.negating = criteria.isNegating(); + orCriteria.recalculateHashCode(); return orCriteria; } @@ -335,6 +348,7 @@ public Criteria subCriteria(Criteria criteria) { Assert.notNull(criteria, "criteria must not be null"); subCriteria.add(criteria); + recalculateHashCode(); return this; } @@ -349,6 +363,7 @@ public Criteria subCriteria(Criteria criteria) { */ public Criteria is(Object o) { queryCriteriaEntries.add(new CriteriaEntry(OperationKey.EQUALS, o)); + recalculateHashCode(); return this; } @@ -360,6 +375,7 @@ public Criteria is(Object o) { */ public Criteria exists() { queryCriteriaEntries.add(new CriteriaEntry(OperationKey.EXISTS)); + recalculateHashCode(); return this; } @@ -378,6 +394,7 @@ public Criteria between(@Nullable Object lowerBound, @Nullable Object upperBound } queryCriteriaEntries.add(new CriteriaEntry(OperationKey.BETWEEN, new Object[] { lowerBound, upperBound })); + recalculateHashCode(); return this; } @@ -393,6 +410,7 @@ public Criteria startsWith(String s) { assertNoBlankInWildcardQuery(s, false, true); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.STARTS_WITH, s)); + recalculateHashCode(); return this; } @@ -409,6 +427,7 @@ public Criteria contains(String s) { assertNoBlankInWildcardQuery(s, true, true); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.CONTAINS, s)); + recalculateHashCode(); return this; } @@ -425,6 +444,7 @@ public Criteria endsWith(String s) { assertNoBlankInWildcardQuery(s, true, false); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.ENDS_WITH, s)); + recalculateHashCode(); return this; } @@ -452,6 +472,7 @@ public Criteria in(Iterable values) { Assert.notNull(values, "Collection of 'in' values must not be null"); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.IN, values)); + recalculateHashCode(); return this; } @@ -478,6 +499,7 @@ public Criteria notIn(Iterable values) { Assert.notNull(values, "Collection of 'NotIn' values must not be null"); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.NOT_IN, values)); + recalculateHashCode(); return this; } @@ -490,6 +512,7 @@ public Criteria notIn(Iterable values) { */ public Criteria expression(String s) { queryCriteriaEntries.add(new CriteriaEntry(OperationKey.EXPRESSION, s)); + recalculateHashCode(); return this; } @@ -501,6 +524,7 @@ public Criteria expression(String s) { */ public Criteria fuzzy(String s) { queryCriteriaEntries.add(new CriteriaEntry(OperationKey.FUZZY, s)); + recalculateHashCode(); return this; } @@ -515,6 +539,7 @@ public Criteria lessThanEqual(Object upperBound) { Assert.notNull(upperBound, "upperBound must not be null"); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.LESS_EQUAL, upperBound)); + recalculateHashCode(); return this; } @@ -529,6 +554,7 @@ public Criteria lessThan(Object upperBound) { Assert.notNull(upperBound, "upperBound must not be null"); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.LESS, upperBound)); + recalculateHashCode(); return this; } @@ -543,6 +569,7 @@ public Criteria greaterThanEqual(Object lowerBound) { Assert.notNull(lowerBound, "lowerBound must not be null"); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.GREATER_EQUAL, lowerBound)); + recalculateHashCode(); return this; } @@ -557,6 +584,7 @@ public Criteria greaterThan(Object lowerBound) { Assert.notNull(lowerBound, "lowerBound must not be null"); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.GREATER, lowerBound)); + recalculateHashCode(); return this; } @@ -573,6 +601,7 @@ public Criteria matches(Object value) { Assert.notNull(value, "value must not be null"); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.MATCHES, value)); + recalculateHashCode(); return this; } @@ -589,6 +618,7 @@ public Criteria matchesAll(Object value) { Assert.notNull(value, "value must not be null"); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.MATCHES_ALL, value)); + recalculateHashCode(); return this; } @@ -601,6 +631,7 @@ public Criteria matchesAll(Object value) { public Criteria empty() { queryCriteriaEntries.add(new CriteriaEntry(OperationKey.EMPTY)); + recalculateHashCode(); return this; } @@ -613,6 +644,7 @@ public Criteria empty() { public Criteria notEmpty() { queryCriteriaEntries.add(new CriteriaEntry(OperationKey.NOT_EMPTY)); + recalculateHashCode(); return this; } @@ -628,6 +660,7 @@ public Criteria regexp(String value) { Assert.notNull(value, "value must not be null"); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.REGEXP, value)); + recalculateHashCode(); return this; } @@ -646,6 +679,7 @@ public Criteria boundedBy(GeoBox boundingBox) { Assert.notNull(boundingBox, "boundingBox value for boundedBy criteria must not be null"); filterCriteriaEntries.add(new CriteriaEntry(OperationKey.BBOX, new Object[] { boundingBox })); + recalculateHashCode(); return this; } @@ -662,6 +696,7 @@ public Criteria boundedBy(Box boundingBox) { filterCriteriaEntries .add(new CriteriaEntry(OperationKey.BBOX, new Object[] { boundingBox.getFirst(), boundingBox.getSecond() })); + recalculateHashCode(); return this; } @@ -679,6 +714,7 @@ public Criteria boundedBy(String topLeftGeohash, String bottomRightGeohash) { filterCriteriaEntries .add(new CriteriaEntry(OperationKey.BBOX, new Object[] { topLeftGeohash, bottomRightGeohash })); + recalculateHashCode(); return this; } @@ -695,6 +731,7 @@ public Criteria boundedBy(GeoPoint topLeftPoint, GeoPoint bottomRightPoint) { Assert.notNull(bottomRightPoint, "bottomRightPoint must not be null"); filterCriteriaEntries.add(new CriteriaEntry(OperationKey.BBOX, new Object[] { topLeftPoint, bottomRightPoint })); + recalculateHashCode(); return this; } @@ -712,6 +749,7 @@ public Criteria boundedBy(Point topLeftPoint, Point bottomRightPoint) { filterCriteriaEntries.add(new CriteriaEntry(OperationKey.BBOX, new Object[] { GeoPoint.fromPoint(topLeftPoint), GeoPoint.fromPoint(bottomRightPoint) })); + recalculateHashCode(); return this; } @@ -729,6 +767,7 @@ public Criteria within(GeoPoint location, String distance) { Assert.notNull(location, "Distance value for near criteria must not be null"); filterCriteriaEntries.add(new CriteriaEntry(OperationKey.WITHIN, new Object[] { location, distance })); + recalculateHashCode(); return this; } @@ -745,6 +784,7 @@ public Criteria within(Point location, Distance distance) { Assert.notNull(location, "Distance value for near criteria must not be null"); filterCriteriaEntries.add(new CriteriaEntry(OperationKey.WITHIN, new Object[] { location, distance })); + recalculateHashCode(); return this; } @@ -761,6 +801,7 @@ public Criteria within(String geoLocation, String distance) { Assert.isTrue(StringUtils.hasLength(geoLocation), "geoLocation value must not be null"); filterCriteriaEntries.add(new CriteriaEntry(OperationKey.WITHIN, new Object[] { geoLocation, distance })); + recalculateHashCode(); return this; } @@ -775,6 +816,7 @@ public Criteria intersects(GeoJson geoShape) { Assert.notNull(geoShape, "geoShape must not be null"); filterCriteriaEntries.add(new CriteriaEntry(OperationKey.GEO_INTERSECTS, geoShape)); + recalculateHashCode(); return this; } @@ -789,6 +831,7 @@ public Criteria isDisjoint(GeoJson geoShape) { Assert.notNull(geoShape, "geoShape must not be null"); filterCriteriaEntries.add(new CriteriaEntry(OperationKey.GEO_IS_DISJOINT, geoShape)); + recalculateHashCode(); return this; } @@ -802,6 +845,7 @@ public Criteria within(GeoJson geoShape) { Assert.notNull(geoShape, "geoShape must not be null"); filterCriteriaEntries.add(new CriteriaEntry(OperationKey.GEO_WITHIN, geoShape)); + recalculateHashCode(); return this; } @@ -815,6 +859,7 @@ public Criteria contains(GeoJson geoShape) { Assert.notNull(geoShape, "geoShape must not be null"); filterCriteriaEntries.add(new CriteriaEntry(OperationKey.GEO_CONTAINS, geoShape)); + recalculateHashCode(); return this; } @@ -828,6 +873,7 @@ public Criteria hasChild(HasChildQuery query) { Assert.notNull(query, "has_child query must not be null."); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.HAS_CHILD, query)); + recalculateHashCode(); return this; } @@ -841,6 +887,7 @@ public Criteria hasParent(HasParentQuery query) { Assert.notNull(query, "has_parent query must not be null."); queryCriteriaEntries.add(new CriteriaEntry(OperationKey.HAS_PARENT, query)); + recalculateHashCode(); return this; } // endregion @@ -887,18 +934,22 @@ public boolean equals(Object o) { @Override public int hashCode() { - int result = field != null ? field.hashCode() : 0; - result = 31 * result + (boost != +0.0f ? Float.floatToIntBits(boost) : 0); - result = 31 * result + (negating ? 1 : 0); - // the criteriaChain contains "this" object, so we need to filter it out - // to avoid a stackoverflow here, because the hashcode implementation - // uses the element's hashcodes - result = 31 * result + criteriaChain.filter(this).hashCode(); - result = 31 * result + queryCriteriaEntries.hashCode(); - result = 31 * result + filterCriteriaEntries.hashCode(); - result = 31 * result + subCriteria.hashCode(); - return result; - } + return hashCode; + } + + private void recalculateHashCode() { + int result = field != null ? field.hashCode() : 0; + result = 31 * result + (boost != +0.0f ? Float.floatToIntBits(boost) : 0); + result = 31 * result + (negating ? 1 : 0); + // the criteriaChain contains "this" object, so we need to filter it out + // to avoid a stackoverflow here, because the hashcode implementation + // uses the element's hashcodes + result = 31 * result + criteriaChain.filter(this).hashCode(); + result = 31 * result + queryCriteriaEntries.hashCode(); + result = 31 * result + filterCriteriaEntries.hashCode(); + result = 31 * result + subCriteria.hashCode(); + this.hashCode = result; + } // endregion @Override diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java b/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java index 2a0dd17b7b..d8d2b3262a 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/Query.java @@ -484,6 +484,13 @@ default Integer getReactiveBatchSize() { */ List getScriptedFields(); + /** + * @return the number of documents that should be requested from Elasticsearch in this query. Depends wether a + * Pageable and/or maxResult size is set on the query. + * @since 5.4.8 5.5.2 + */ + public Integer getRequestSize(); + /** * @since 4.3 */ diff --git a/src/main/resources/notice.txt b/src/main/resources/notice.txt index 7f6d7c5823..85e26f2351 100644 --- a/src/main/resources/notice.txt +++ b/src/main/resources/notice.txt @@ -1,4 +1,4 @@ -Spring Data Elasticsearch 5.5 GA (2025.0.0) +Spring Data Elasticsearch 5.5.6 (2025.0.6) Copyright (c) [2013-2022] Pivotal Software, Inc. This product is licensed to you under the Apache License, Version 2.0 (the "License"). @@ -22,6 +22,12 @@ conditions of the subcomponent's license, as noted in the LICENSE file. + + + + + + diff --git a/src/test/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryMappingUnitTests.java b/src/test/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryMappingUnitTests.java index e53a33df67..7d40f824cb 100644 --- a/src/test/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryMappingUnitTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/client/elc/CriteriaQueryMappingUnitTests.java @@ -448,7 +448,7 @@ void shouldMapNamesInSourceStoredFields() { } // the following test failed because of a wrong implementation in Criteria - // equals and hscode methods. + // equals and hashcode methods. @Test // #3083 @DisplayName("should map correct subcriteria") void shouldMapCorrectSubcriteria() throws JSONException { diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java index 98449b4cce..55aa1190f5 100755 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java @@ -105,8 +105,6 @@ @SpringIntegrationTest public abstract class ElasticsearchIntegrationTests { - static final Integer INDEX_MAX_RESULT_WINDOW = 10_000; - private static final String MULTI_INDEX_PREFIX = "test-index"; private static final String MULTI_INDEX_ALL = MULTI_INDEX_PREFIX + "*"; private static final String MULTI_INDEX_1_NAME = MULTI_INDEX_PREFIX + "-1"; diff --git a/src/test/java/org/springframework/data/elasticsearch/core/query/BaseQueryTests.java b/src/test/java/org/springframework/data/elasticsearch/core/query/BaseQueryTests.java new file mode 100644 index 0000000000..946d710f47 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/query/BaseQueryTests.java @@ -0,0 +1,106 @@ +/* + * 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.elasticsearch.core.query; + +import static org.assertj.core.api.Assertions.*; +import static org.springframework.data.elasticsearch.core.query.BaseQuery.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Pageable; + +class BaseQueryTests { + + private static final String MATCH_ALL_QUERY = "{\"match_all\":{}}"; + + @Test // #3127 + @DisplayName("query with no Pageable and no maxResults requests 10 docs from 0") + void queryWithNoPageableAndNoMaxResultsRequests10DocsFrom0() { + + var query = StringQuery.builder(MATCH_ALL_QUERY) + .build(); + + var requestSize = query.getRequestSize(); + + assertThat(requestSize).isEqualTo(10); + } + + @Test // #3127 + @DisplayName("query with a Pageable and no MaxResults request with values from Pageable") + void queryWithAPageableAndNoMaxResultsRequestWithValuesFromPageable() { + var query = StringQuery.builder(MATCH_ALL_QUERY) + .withPageable(Pageable.ofSize(42)) + .build(); + + var requestSize = query.getRequestSize(); + + assertThat(requestSize).isEqualTo(42); + } + + @Test // #3127 + @DisplayName("query with no Pageable and maxResults requests maxResults") + void queryWithNoPageableAndMaxResultsRequestsMaxResults() { + + var query = StringQuery.builder(MATCH_ALL_QUERY) + .withMaxResults(12_345) + .build(); + + var requestSize = query.getRequestSize(); + + assertThat(requestSize).isEqualTo(12_345); + } + + @Test // #3127 + @DisplayName("query with Pageable and maxResults requests with values from Pageable if Pageable is less than maxResults") + void queryWithPageableAndMaxResultsRequestsWithValuesFromPageableIfPageableIsLessThanMaxResults() { + + var query = StringQuery.builder(MATCH_ALL_QUERY) + .withPageable(Pageable.ofSize(42)) + .withMaxResults(123) + .build(); + + var requestSize = query.getRequestSize(); + + assertThat(requestSize).isEqualTo(42); + } + + @Test // #3127 + @DisplayName("query with Pageable and maxResults requests with values from maxResults if Pageable is more than maxResults") + void queryWithPageableAndMaxResultsRequestsWithValuesFromMaxResultsIfPageableIsMoreThanMaxResults() { + + var query = StringQuery.builder(MATCH_ALL_QUERY) + .withPageable(Pageable.ofSize(420)) + .withMaxResults(123) + .build(); + + var requestSize = query.getRequestSize(); + + assertThat(requestSize).isEqualTo(123); + } + + @Test // #3127 + @DisplayName("query with explicit unpaged request and no maxResults requests max request window size") + void queryWithExplicitUnpagedRequestAndNoMaxResultsRequestsMaxRequestWindowSize() { + + var query = StringQuery.builder(MATCH_ALL_QUERY) + .withPageable(Pageable.unpaged()) + .build(); + + var requestSize = query.getRequestSize(); + + assertThat(requestSize).isEqualTo(INDEX_MAX_RESULT_WINDOW); + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/query/CriteriaTest.java b/src/test/java/org/springframework/data/elasticsearch/core/query/CriteriaTest.java new file mode 100644 index 0000000000..2e8c264b31 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/query/CriteriaTest.java @@ -0,0 +1,45 @@ +/* + * Copyright 2019-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.elasticsearch.core.query; + +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * @author Peter-Josef Meisch + */ +class CriteriaTest { + + @Test // #3159 + @DisplayName("should not slow down on calculating hashcode for long criteria chains") + void shouldNotSlowDownOnCalculatingHashcodeForLongCriteriaChains() { + assertTimeoutPreemptively(Duration.of(1, ChronoUnit.SECONDS), () -> { + var criteria = new Criteria(); + var size = 1000; + for (int i = 1; i <= size; i++) { + criteria = criteria.or("field-" + i).contains("value-" + i); + } + final var criteriaChain = criteria.getCriteriaChain(); + assertEquals(size, criteriaChain.size()); + final var hashCode = Integer.valueOf(criteria.hashCode()); + }); + } +} diff --git a/src/test/resources/testcontainers-elasticsearch.properties b/src/test/resources/testcontainers-elasticsearch.properties index 6860b4150e..5d58b4c889 100644 --- a/src/test/resources/testcontainers-elasticsearch.properties +++ b/src/test/resources/testcontainers-elasticsearch.properties @@ -15,7 +15,7 @@ # # sde.testcontainers.image-name=docker.elastic.co/elasticsearch/elasticsearch -sde.testcontainers.image-version=8.18.1 +sde.testcontainers.image-version=8.18.8 # # # needed as we do a DELETE /* at the end of the tests, will be required from 8.0 on, produces a warning since 7.13