diff --git a/src/main/antora/modules/ROOT/pages/elasticsearch/elasticsearch-new.adoc b/src/main/antora/modules/ROOT/pages/elasticsearch/elasticsearch-new.adoc index d04e519ea..d7f9d05db 100644 --- a/src/main/antora/modules/ROOT/pages/elasticsearch/elasticsearch-new.adoc +++ b/src/main/antora/modules/ROOT/pages/elasticsearch/elasticsearch-new.adoc @@ -7,6 +7,7 @@ * Upgrade to Elasticsearch 9.4.4 * Allow to provide non-standard (custom) index options for `@Field` / `@InnerField` * Support Elasticsearch Serverless +* Support propert resolution in index name expressions (https://github.com/spring-projects/spring-data-elasticsearch/issues/3310[GH #3310]) [[new-features.6-1-0]] == New in Spring Data Elasticsearch 6.1 diff --git a/src/main/antora/modules/ROOT/pages/elasticsearch/object-mapping.adoc b/src/main/antora/modules/ROOT/pages/elasticsearch/object-mapping.adoc index 6ca12728c..b2c6d40f3 100644 --- a/src/main/antora/modules/ROOT/pages/elasticsearch/object-mapping.adoc +++ b/src/main/antora/modules/ROOT/pages/elasticsearch/object-mapping.adoc @@ -22,7 +22,7 @@ The following annotations are available: * `@Document`: Applied at the class level to indicate this class is a candidate for mapping to the database. The most important attributes are (check the API documentation for the complete list of attributes): ** `indexName`: the name of the index to store this entity in. -This can contain a SpEL template expression like `"log-#{T(java.time.LocalDate).now().toString()}"` +This can contain a SpEL template expression like `"log-#{T(java.time.LocalDate).now().toString()}"` or a property expression like `{index.name}` ** `createIndex`: flag whether to create an index on repository bootstrapping. Default value is _true_. See xref:elasticsearch/repositories/elasticsearch-repositories.adoc#elasticsearch.repositories.autocreation[Automatic creation of indices with the corresponding mapping] diff --git a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java index 6c30a1048..f82b0c1ab 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntity.java @@ -38,6 +38,9 @@ import org.springframework.data.elasticsearch.core.index.Settings; import org.springframework.data.elasticsearch.core.join.JoinField; import org.springframework.data.elasticsearch.core.query.Query; import org.springframework.data.elasticsearch.core.query.StringQuery; +import org.springframework.data.elasticsearch.utils.spel.ExpressionUtils; +import org.springframework.data.expression.ValueEvaluationContext; +import org.springframework.data.expression.ValueExpression; import org.springframework.data.mapping.MappingException; import org.springframework.data.mapping.PropertyHandler; import org.springframework.data.mapping.model.BasicPersistentEntity; @@ -52,6 +55,7 @@ import org.springframework.expression.ParserContext; import org.springframework.expression.common.LiteralExpression; import org.springframework.expression.spel.standard.SpelExpressionParser; import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; /** @@ -75,7 +79,7 @@ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntit private static final SpelExpressionParser PARSER = new SpelExpressionParser(); private @Nullable final Document document; - private @Nullable String indexName; + private final String unresolvedIndexName; private final Lazy settingsParameter; private @Nullable ElasticsearchPersistentProperty seqNoPrimaryTermProperty; private @Nullable ElasticsearchPersistentProperty joinFieldProperty; @@ -90,8 +94,7 @@ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntit private final ContextConfiguration contextConfiguration; private final Set aliases = new HashSet<>(); - private final ConcurrentHashMap indexNameExpressions = new ConcurrentHashMap<>(); - private final Lazy indexNameEvaluationContext = Lazy.of(this::getIndexNameEvaluationContext); + private final Lazy indexNameEvaluationContext = Lazy.of(this::getIndexNameEvaluationContext); private final boolean storeIdInSource; private final boolean storeVersionInSource; @@ -111,10 +114,10 @@ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntit this.settingsParameter = Lazy.of(() -> buildSettingsParameter(clazz)); if (document != null) { - Assert.hasText(document.indexName(), " Unknown indexName. Make sure the indexName is defined. e.g @Document(indexName=\"foo\")"); - this.indexName = document.indexName(); + + this.unresolvedIndexName = document.indexName(); this.versionType = document.versionType(); this.createIndexAndMapping = document.createIndex(); this.alwaysWriteMapping = document.alwaysWriteMapping(); @@ -123,6 +126,7 @@ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntit this.storeVersionInSource = document.storeVersionInSource(); buildAliases(); } else { + this.unresolvedIndexName = getTypeInformation().getType().getSimpleName(); this.dynamic = Dynamic.INHERIT; this.storeIdInSource = true; this.storeVersionInSource = true; @@ -139,13 +143,9 @@ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntit } } - private String getIndexName() { - return indexName != null ? indexName : getTypeInformation().getType().getSimpleName(); - } - @Override public IndexCoordinates getIndexCoordinates() { - return resolve(IndexCoordinates.of(getIndexName())); + return resolve(IndexCoordinates.of(unresolvedIndexName)); } @Override @@ -376,42 +376,24 @@ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntit Assert.notNull(name, "name must not be null"); - Expression expression = getExpressionForIndexName(name); + ValueExpression expression = ExpressionUtils.detectExpression(name); - String resolvedName = expression != null ? expression.getValue(indexNameEvaluationContext.get(), String.class) - : null; - return resolvedName != null ? resolvedName : name; + Object resolvedName = expression != null ? expression.evaluate(indexNameEvaluationContext.get()) : null; + return resolvedName != null ? ObjectUtils.nullSafeToString(resolvedName) : name; } /** - * returns an {@link Expression} for #name if name contains a {@link ParserContext#TEMPLATE_EXPRESSION} otherwise - * returns {@literal null}. - * - * @param name the name to get the expression for - * @return Expression may be null - */ - @Nullable - private Expression getExpressionForIndexName(String name) { - return indexNameExpressions.computeIfAbsent(name, s -> { - Expression expr = PARSER.parseExpression(s, ParserContext.TEMPLATE_EXPRESSION); - return expr instanceof LiteralExpression ? null : expr; - }); - } - - /** - * build the {@link EvaluationContext} considering {@link ExpressionDependencies} from the name returned by - * {@link #getIndexName()}. + * build the {@link EvaluationContext} considering {@link ExpressionDependencies} from the unresolvedIndexName. * * @return EvaluationContext */ - private EvaluationContext getIndexNameEvaluationContext() { + private ValueEvaluationContext getIndexNameEvaluationContext() { - Expression expression = getExpressionForIndexName(getIndexName()); - ExpressionDependencies expressionDependencies = expression != null ? ExpressionDependencies.discover(expression) - : ExpressionDependencies.none(); + ValueExpression expression = ExpressionUtils.detectExpression(unresolvedIndexName); + var expressionDependencies = expression != null ? expression.getExpressionDependencies() : null; - // noinspection ConstantConditions - return getEvaluationContext(null, expressionDependencies); + return expressionDependencies != null ? getValueEvaluationContext(null, expressionDependencies) + : getValueEvaluationContext(null); } @Override diff --git a/src/main/java/org/springframework/data/elasticsearch/utils/spel/ExpressionUtils.java b/src/main/java/org/springframework/data/elasticsearch/utils/spel/ExpressionUtils.java new file mode 100644 index 000000000..2c2b1f550 --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/utils/spel/ExpressionUtils.java @@ -0,0 +1,81 @@ +/* + * Copyright 2021-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.elasticsearch.utils.spel; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; +import org.springframework.data.expression.ValueEvaluationContext; +import org.springframework.data.expression.ValueExpression; +import org.springframework.data.expression.ValueExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.lang.Contract; +import org.springframework.util.StringUtils; + +/** + * Internal utility class for dealing with {@link ValueExpression} and potential ones. Shamelessly copied from Spring + * Data MongoDB. Adapted afterwards to our needs. + * + * @author Christoph Strobl + * @since 6.2 + */ +public final class ExpressionUtils { + + private static final ValueExpressionParser PARSER = ValueExpressionParser.create(SpelExpressionParser::new); + + private static final ConcurrentHashMap expressionCache = new ConcurrentHashMap<>(); + + /** + * Returns a SpEL {@link ValueExpression} if the given {@link String} is not empty. ValueExpressions are stored in a + * cash. + * + * @param potentialExpression can be {@literal null} + * @return {@link ValueExpression} or null when input is empty + */ + @Contract("null -> null") + public static @Nullable ValueExpression detectExpression(@Nullable String potentialExpression) { + + if (!StringUtils.hasText(potentialExpression)) { + return null; + } + + return expressionCache.computeIfAbsent(potentialExpression, + key -> PARSER.parse(potentialExpression)); + } + + /** + * evbaluates value against the context provided by valueEvaluationContextSupplier. If value cannot be transformed + * into a {@link ValueExpression}, the defaultValue is returned. + * + * @param value the value to evaluate + * @param valueEvaluationContextSupplier supplies the context to use for evaluation + * @param defaultValue + * @return + */ + public static @Nullable Object evaluate(String value, Supplier valueEvaluationContextSupplier, + @Nullable Object defaultValue) { + + ValueExpression expression = detectExpression(value); + + if (expression == null) { + return defaultValue; + } + + var evaluated = expression.evaluate(valueEvaluationContextSupplier.get()); + return evaluated == null ? defaultValue : evaluated; + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityELCIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityELCIntegrationTests.java new file mode 100644 index 000000000..378820341 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityELCIntegrationTests.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024-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.elasticsearch.core.mapping; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.elasticsearch.junit.jupiter.ElasticsearchTemplateConfiguration; +import org.springframework.data.elasticsearch.utils.IndexNameProvider; + +public class SimpleElasticsearchPersistentEntityELCIntegrationTests extends SimpleElasticsearchPersistentEntityIntegrationTests { + @Configuration + static class Config extends ElasticsearchTemplateConfiguration { + + @Bean + IndexNameProvider indexNameProvider() { + return new IndexNameProvider("persistent-entity"); + } + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityIntegrationTests.java b/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityIntegrationTests.java new file mode 100644 index 000000000..f1bb6ae6f --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentEntityIntegrationTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2021-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.elasticsearch.core.mapping; +/* + * Copyright 2024-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. + */ + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.junit.jupiter.SpringIntegrationTest; +import org.springframework.data.elasticsearch.utils.IndexNameProvider; +import org.springframework.test.context.TestPropertySource; + +/** + * @author Peter-Josef Meisch + */ +@SpringIntegrationTest +@TestPropertySource(properties = { "entity.indexName = index-property" }) +public abstract class SimpleElasticsearchPersistentEntityIntegrationTests { + + @Autowired private ElasticsearchOperations operations; + @Autowired private IndexNameProvider indexNameProvider; + + @BeforeEach + void setUp() { + indexNameProvider.increment(); + } + + @Test // #3310 + @DisplayName("should evaluate static index name") + void shouldEvaluateStaticIndexName() { + + var persistentEntity = getRequiredPersistentEntity(EntityWithStaticName.class); + + assertThat(persistentEntity.getIndexCoordinates().getIndexName()).isEqualTo("static-name"); + } + + @Test // #3310 + @DisplayName("should evaluate SpEL index name") + void shouldEvaluateSpELIndexName() { + + var persistentEntity = getRequiredPersistentEntity(EntityWithSpel.class); + + assertThat(persistentEntity.getIndexCoordinates().getIndexName()).isEqualTo(indexNameProvider.indexName()); + } + + @Test // #3310 + @DisplayName("should evaluate property index name") + void shouldEvaluatePropertyIndexName() { + + var persistentEntity = getRequiredPersistentEntity(EntityWithProperty.class); + + assertThat(persistentEntity.getIndexCoordinates().getIndexName()).isEqualTo("index-property"); + } + + private ElasticsearchPersistentEntity getRequiredPersistentEntity(Class clazz) { + return operations.getElasticsearchConverter().getMappingContext().getRequiredPersistentEntity(clazz); + } + + @Document(indexName = "static-name") + static class EntityWithStaticName {} + + @Document(indexName = "#{@indexNameProvider.indexName()}") + static class EntityWithSpel {} + + @Document(indexName = "${entity.indexName}") + static class EntityWithProperty {} + +}