diff --git a/src/main/asciidoc/reference/elasticsearch-object-mapping.adoc b/src/main/asciidoc/reference/elasticsearch-object-mapping.adoc index 8c8c60211..130363d10 100644 --- a/src/main/asciidoc/reference/elasticsearch-object-mapping.adoc +++ b/src/main/asciidoc/reference/elasticsearch-object-mapping.adoc @@ -159,6 +159,27 @@ It is also possible to define a `FieldNamingStrategy` in the configuration of th If for example a `SnakeCaseFieldNamingStrategy` is configured, the property _sampleProperty_ of the object would be mapped to _sample_property_ in Elasticsearch. A `FieldNamingStrategy` applies to all entities; it can be overwritten by setting a specific name with `@Field` on a property. +[[elasticsearch.mapping.meta-model.annotations.non-field-backed-properties]] +==== Non-field-backed properties + +Normally the properties used in an entity are fields of the entity class. There might be cases, when a property value +is calculated in the entity and should be stored in Elasticsearch. In this case, the getter method (`getProperty()`) can be +annotated +with the `@Field` annotation, in addition to that the method must be annotated with `@AccessType(AccessType.Type +.PROPERTY)`. The third annotation that is needed in such a case is `@WriteOnlyProperty`, as such a value is only +written to Elasticsearch. A full example: +==== +[source,java] +---- +@Field(type = Keyword) +@WriteOnlyProperty +@AccessType(AccessType.Type.PROPERTY) +public String getProperty() { + return "some value that is calculated here"; +} +---- +==== + [[elasticsearch.mapping.meta-model.rules]] === Mapping Rules diff --git a/src/main/java/org/springframework/data/elasticsearch/annotations/Field.java b/src/main/java/org/springframework/data/elasticsearch/annotations/Field.java index e83e42bf0..4ecfb7071 100644 --- a/src/main/java/org/springframework/data/elasticsearch/annotations/Field.java +++ b/src/main/java/org/springframework/data/elasticsearch/annotations/Field.java @@ -39,7 +39,7 @@ import org.springframework.core.annotation.AliasFor; * @author Sascha Woo */ @Retention(RetentionPolicy.RUNTIME) -@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE }) +@Target({ ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.METHOD }) @Documented @Inherited public @interface Field { diff --git a/src/main/java/org/springframework/data/elasticsearch/annotations/WriteOnlyProperty.java b/src/main/java/org/springframework/data/elasticsearch/annotations/WriteOnlyProperty.java new file mode 100644 index 000000000..7c940f94a --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/annotations/WriteOnlyProperty.java @@ -0,0 +1,35 @@ +/* + * Copyright 2022 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF 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.annotations; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to mark a property that will be written to Elasticsearch, but not set when reading from Elasticsearch. + * This is needed for synthesized fields that may be used for search but that are not available in the entity. + * + * @author Peter-Josef Meisch + * @since 5.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.METHOD, ElementType.FIELD }) +@Documented +public @interface WriteOnlyProperty { +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentProperty.java b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentProperty.java index 0aecc6852..d290bb7cb 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentProperty.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/mapping/SimpleElasticsearchPersistentProperty.java @@ -32,6 +32,7 @@ import org.springframework.data.elasticsearch.annotations.GeoPointField; import org.springframework.data.elasticsearch.annotations.GeoShapeField; import org.springframework.data.elasticsearch.annotations.MultiField; import org.springframework.data.elasticsearch.annotations.ValueConverter; +import org.springframework.data.elasticsearch.annotations.WriteOnlyProperty; import org.springframework.data.elasticsearch.core.convert.DatePropertyValueConverter; import org.springframework.data.elasticsearch.core.convert.DateRangePropertyValueConverter; import org.springframework.data.elasticsearch.core.convert.ElasticsearchDateConverter; @@ -123,7 +124,7 @@ public class SimpleElasticsearchPersistentProperty extends @Override public boolean isReadable() { - return !isTransient() && !isSeqNoPrimaryTermProperty(); + return !isTransient() && !isSeqNoPrimaryTermProperty() && !isAnnotationPresent(WriteOnlyProperty.class); } @Override 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 949ad383f..4af0393cf 100755 --- a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchIntegrationTests.java @@ -68,6 +68,7 @@ import org.springframework.data.elasticsearch.annotations.JoinTypeRelations; import org.springframework.data.elasticsearch.annotations.MultiField; import org.springframework.data.elasticsearch.annotations.ScriptedField; import org.springframework.data.elasticsearch.annotations.Setting; +import org.springframework.data.elasticsearch.annotations.WriteOnlyProperty; import org.springframework.data.elasticsearch.client.erhlc.NativeSearchQueryBuilder; import org.springframework.data.elasticsearch.core.document.Explanation; import org.springframework.data.elasticsearch.core.geo.GeoPoint; @@ -197,8 +198,7 @@ public abstract class ElasticsearchIntegrationTests { SampleEntity sampleEntity = SampleEntity.builder().id(documentId).message(messageBeforeUpdate) .version(System.currentTimeMillis()).build(); - assertThatThrownBy(() -> operations.update(sampleEntity)) - .isInstanceOf(DataAccessException.class); + assertThatThrownBy(() -> operations.update(sampleEntity)).isInstanceOf(DataAccessException.class); } @Test @@ -1529,8 +1529,8 @@ public abstract class ElasticsearchIntegrationTests { String messageAfterUpdate = "test message"; String originalTypeInfo = "some type"; - SampleEntity sampleEntity = SampleEntity.builder().id(documentId).message(messageBeforeUpdate).type(originalTypeInfo) - .version(System.currentTimeMillis()).build(); + SampleEntity sampleEntity = SampleEntity.builder().id(documentId).message(messageBeforeUpdate) + .type(originalTypeInfo).version(System.currentTimeMillis()).build(); operations.save(sampleEntity); // modify the entity @@ -3741,6 +3741,26 @@ public abstract class ElasticsearchIntegrationTests { assertThat(readEntity.getPart2()).isEqualTo(entity.getPart2()); } + @Test // #1489 + @DisplayName("should handle non-field-backed properties") + void shouldHandleNonFieldBackedProperties() { + + operations.indexOps(NonFieldBackedPropertyClass.class).createWithMapping(); + + var entity = new NonFieldBackedPropertyClass(); + entity.setId("007"); + entity.setFirstName("James"); + entity.setLastName("Bond"); + + operations.save(entity); + + var searchHits = operations.search(new CriteriaQuery(Criteria.where("fullName").is("jamesbond")), + NonFieldBackedPropertyClass.class); + + assertThat(searchHits.getTotalHits()).isEqualTo(1); + assertThat(searchHits.getSearchHit(0).getContent()).isEqualTo(entity); + } + @Document(indexName = "#{@indexNameProvider.indexName()}") private static class SampleEntityUUIDKeyed { @Nullable @@ -4517,5 +4537,78 @@ public abstract class ElasticsearchIntegrationTests { } } + @Document(indexName = "#{@indexNameProvider.indexName()}-readonly-id") + static class NonFieldBackedPropertyClass { + @Id + @Nullable private String id; + + @Nullable + @Field(type = Text) private String firstName; + + @Nullable + @Field(type = Text) private String lastName; + +@Field(type = Keyword) +@WriteOnlyProperty +@AccessType(AccessType.Type.PROPERTY) +public String getFullName() { + return sanitize(firstName) + sanitize(lastName); +} + + @Nullable + public String getId() { + return id; + } + + public void setId(@Nullable String id) { + this.id = id; + } + + @Nullable + public String getFirstName() { + return firstName; + } + + public void setFirstName(@Nullable String firstName) { + this.firstName = firstName; + } + + @Nullable + public String getLastName() { + return lastName; + } + + public void setLastName(@Nullable String lastName) { + this.lastName = lastName; + } + + private String sanitize(@Nullable String s) { + return s == null ? "" : s.replaceAll("\\s", "").toLowerCase(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + NonFieldBackedPropertyClass that = (NonFieldBackedPropertyClass) o; + + if (!Objects.equals(id, that.id)) + return false; + if (!Objects.equals(firstName, that.firstName)) + return false; + return Objects.equals(lastName, that.lastName); + } + + @Override + public int hashCode() { + int result = id != null ? id.hashCode() : 0; + result = 31 * result + (firstName != null ? firstName.hashCode() : 0); + result = 31 * result + (lastName != null ? lastName.hashCode() : 0); + return result; + } + } // endregion }