diff --git a/src/main/java/org/springframework/data/elasticsearch/annotations/Parent.java b/src/main/java/org/springframework/data/elasticsearch/annotations/Parent.java new file mode 100644 index 000000000..a0208ad7b --- /dev/null +++ b/src/main/java/org/springframework/data/elasticsearch/annotations/Parent.java @@ -0,0 +1,34 @@ +/* + * Copyright 2014 the original author 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 + * + * http://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.*; + +import org.springframework.data.annotation.Persistent; + +/** + * Parent + * + * @author Philipp Jardas + */ + +@Persistent +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface Parent { + String type(); +} diff --git a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java index 7af3b6042..e33730043 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplate.java @@ -122,7 +122,7 @@ public class ElasticsearchTemplate implements ElasticsearchOperations { try { XContentBuilder xContentBuilder = buildMapping(clazz, persistentEntity.getIndexType(), persistentEntity - .getIdProperty().getFieldName()); + .getIdProperty().getFieldName(), persistentEntity.getParentType()); return requestBuilder.setSource(xContentBuilder).execute().actionGet().isAcknowledged(); } catch (Exception e) { throw new ElasticsearchException("Failed to build mapping for " + clazz.getSimpleName(), e); @@ -544,6 +544,11 @@ public class ElasticsearchTemplate implements ElasticsearchOperations { indexRequestBuilder.setVersion(query.getVersion()); indexRequestBuilder.setVersionType(EXTERNAL); } + + if (query.getParentId() != null) { + indexRequestBuilder.setParent(query.getParentId()); + } + return indexRequestBuilder; } catch (IOException e) { throw new ElasticsearchException("failed to index the document [id: " + query.getId() + "]", e); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/MappingBuilder.java b/src/main/java/org/springframework/data/elasticsearch/core/MappingBuilder.java index eaad002d5..1b6ad6f3a 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/MappingBuilder.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/MappingBuilder.java @@ -32,6 +32,7 @@ import java.util.Map; import static org.apache.commons.lang.StringUtils.EMPTY; import static org.apache.commons.lang.StringUtils.isNotBlank; import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder; +import static org.springframework.util.StringUtils.hasText; /** * @author Rizwan Idrees @@ -48,6 +49,7 @@ class MappingBuilder { public static final String FIELD_SEARCH_ANALYZER = "search_analyzer"; public static final String FIELD_INDEX_ANALYZER = "index_analyzer"; public static final String FIELD_PROPERTIES = "properties"; + public static final String FIELD_PARENT = "_parent"; public static final String INDEX_VALUE_NOT_ANALYZED = "not_analyzed"; public static final String TYPE_VALUE_STRING = "string"; @@ -55,8 +57,16 @@ class MappingBuilder { private static SimpleTypeHolder SIMPLE_TYPE_HOLDER = new SimpleTypeHolder(); - static XContentBuilder buildMapping(Class clazz, String indexType, String idFieldName) throws IOException { - XContentBuilder xContentBuilder = jsonBuilder().startObject().startObject(indexType).startObject(FIELD_PROPERTIES); + static XContentBuilder buildMapping(Class clazz, String indexType, String idFieldName, String parentType) throws IOException { + + XContentBuilder mapping = jsonBuilder().startObject().startObject(indexType); + // Parent + if (hasText(parentType)) { + mapping.startObject(FIELD_PARENT).field(FIELD_TYPE,parentType).endObject(); + } + + // Properties + XContentBuilder xContentBuilder = mapping.startObject(FIELD_PROPERTIES); mapEntity(xContentBuilder, clazz, true, idFieldName, EMPTY, false); diff --git a/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java b/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java index 8b9ea721d..4c4f28b79 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/mapping/ElasticsearchPersistentEntity.java @@ -39,4 +39,8 @@ public interface ElasticsearchPersistentEntity extends PersistentEntity extends BasicPersistentEntit private short replicas; private String refreshInterval; private String indexStoreType; + private String parentType; + private ElasticsearchPersistentProperty parentIdProperty; public SimpleElasticsearchPersistentEntity(TypeInformation typeInformation) { super(typeInformation); @@ -104,9 +106,29 @@ public class SimpleElasticsearchPersistentEntity extends BasicPersistentEntit return refreshInterval; } + @Override + public String getParentType() { + return parentType; + } + + @Override + public ElasticsearchPersistentProperty getParentIdProperty() { + return parentIdProperty; + } + @Override public void addPersistentProperty(ElasticsearchPersistentProperty property) { super.addPersistentProperty(property); + + Parent parent = property.getField().getAnnotation(Parent.class); + if (parent != null) { + Assert.isNull(this.parentIdProperty, "Only one field can hold a @Parent annotation"); + Assert.isNull(this.parentType, "Only one field can hold a @Parent annotation"); + Assert.isTrue(property.getType() == String.class, "Parent ID property should be String"); + this.parentIdProperty = property; + this.parentType = parent.type(); + } + if (property.isVersionProperty()) { Assert.isTrue(property.getType() == Long.class, "Version property should be Long"); } diff --git a/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQuery.java b/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQuery.java index 5749112ae..1248e1826 100644 --- a/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQuery.java +++ b/src/main/java/org/springframework/data/elasticsearch/core/query/IndexQuery.java @@ -30,6 +30,7 @@ public class IndexQuery { private String indexName; private String type; private String source; + private String parentId; public String getId() { return id; @@ -78,4 +79,12 @@ public class IndexQuery { public void setSource(String source) { this.source = source; } + + public String getParentId() { + return parentId; + } + + public void setParentId(String parentId) { + this.parentId = parentId; + } } diff --git a/src/main/java/org/springframework/data/elasticsearch/repository/support/AbstractElasticsearchRepository.java b/src/main/java/org/springframework/data/elasticsearch/repository/support/AbstractElasticsearchRepository.java index d283ce3fc..bbe036d0e 100644 --- a/src/main/java/org/springframework/data/elasticsearch/repository/support/AbstractElasticsearchRepository.java +++ b/src/main/java/org/springframework/data/elasticsearch/repository/support/AbstractElasticsearchRepository.java @@ -240,6 +240,7 @@ public abstract class AbstractElasticsearchRepository exte String getType(); Long getVersion(T entity); + + String getParentId(T entity); } diff --git a/src/main/java/org/springframework/data/elasticsearch/repository/support/MappingElasticsearchEntityInformation.java b/src/main/java/org/springframework/data/elasticsearch/repository/support/MappingElasticsearchEntityInformation.java index 47f5d0693..9820f5513 100644 --- a/src/main/java/org/springframework/data/elasticsearch/repository/support/MappingElasticsearchEntityInformation.java +++ b/src/main/java/org/springframework/data/elasticsearch/repository/support/MappingElasticsearchEntityInformation.java @@ -104,4 +104,17 @@ public class MappingElasticsearchEntityInformation e } return null; } + + @Override + public String getParentId(T entity) { + ElasticsearchPersistentProperty parentProperty = entityMetadata.getParentIdProperty(); + try { + if (parentProperty != null) { + return (String) BeanWrapper.create(entity, null).getProperty(parentProperty); + } + } catch (Exception e) { + throw new IllegalStateException("failed to load parent ID: " + e, e); + } + return null; + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/MinimalEntity.java b/src/test/java/org/springframework/data/elasticsearch/MinimalEntity.java new file mode 100644 index 000000000..d4b787beb --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/MinimalEntity.java @@ -0,0 +1,29 @@ +/* + * Copyright 2014 the original author 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 + * + * http://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; + +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +/** + * MinimalEntity + * + * @author Philipp Jardas + */ +@Document(indexName = "index", type = "type") +public class MinimalEntity { + @Id + private String id; +} diff --git a/src/test/java/org/springframework/data/elasticsearch/ParentEntity.java b/src/test/java/org/springframework/data/elasticsearch/ParentEntity.java new file mode 100644 index 000000000..5c13c4019 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/ParentEntity.java @@ -0,0 +1,95 @@ +/* + * Copyright 2014 the original author 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 + * + * http://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; + +import org.springframework.core.style.ToStringCreator; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.PersistenceConstructor; +import org.springframework.data.elasticsearch.annotations.*; +/** + * ParentEntity + * + * @author Philipp Jardas + */ +@Document(indexName = ParentEntity.INDEX, type = ParentEntity.PARENT_TYPE, indexStoreType = "memory", shards = 1, replicas = 0, refreshInterval = "-1") +public class ParentEntity { + public static final String INDEX = "parent-child"; + public static final String PARENT_TYPE = "parent-entity"; + public static final String CHILD_TYPE = "child-entity"; + + @Id + private String id; + @Field(type = FieldType.String, index = FieldIndex.analyzed, store = true) + private String name; + + public ParentEntity() { + } + + public ParentEntity(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("id", id).append("name", name).toString(); + } + + @Document(indexName = INDEX, type = CHILD_TYPE, indexStoreType = "memory", shards = 1, replicas = 0, refreshInterval = "-1") + public static class ChildEntity { + @Id + private String id; + @Field(type = FieldType.String, store = true) + @Parent(type = PARENT_TYPE) + private String parentId; + @Field(type = FieldType.String, index = FieldIndex.analyzed, store = true) + private String name; + + public ChildEntity() { + } + + public ChildEntity(String id, String parentId, String name) { + this.id = id; + this.parentId = parentId; + this.name = name; + } + + public String getId() { + return id; + } + + public String getParentId() { + return parentId; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("id", id).append("parentId", parentId).append("name", name).toString(); + } + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateParentChildTests.java b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateParentChildTests.java new file mode 100644 index 000000000..d14496d29 --- /dev/null +++ b/src/test/java/org/springframework/data/elasticsearch/core/ElasticsearchTemplateParentChildTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2014 the original author 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 + * + * http://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; + +import static org.elasticsearch.index.query.QueryBuilders.hasChildQuery; +import static org.elasticsearch.index.query.QueryBuilders.topChildrenQuery; +import static org.hamcrest.Matchers.*; +import static org.junit.Assert.assertThat; + +import java.util.List; + +import org.elasticsearch.index.query.QueryBuilder; +import org.elasticsearch.index.query.QueryBuilders; +import org.junit.*; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.elasticsearch.ParentEntity; +import org.springframework.data.elasticsearch.ParentEntity.ChildEntity; +import org.springframework.data.elasticsearch.core.query.IndexQuery; +import org.springframework.data.elasticsearch.core.query.NativeSearchQuery; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; + +/** + * @author Philipp Jardas + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration("classpath:elasticsearch-template-test.xml") +public class ElasticsearchTemplateParentChildTests { + + @Autowired + private ElasticsearchTemplate elasticsearchTemplate; + + @Before + public void before() { + clean(); + elasticsearchTemplate.createIndex(ParentEntity.class); + elasticsearchTemplate.createIndex(ChildEntity.class); + elasticsearchTemplate.putMapping(ParentEntity.class); + elasticsearchTemplate.putMapping(ChildEntity.class); + } + + @After + public void clean() { + elasticsearchTemplate.deleteIndex(ChildEntity.class); + elasticsearchTemplate.deleteIndex(ParentEntity.class); + } + + @Test + public void shouldIndexParentChildEntity() { + // index two parents + ParentEntity parent1 = index("parent1", "First Parent"); + ParentEntity parent2 = index("parent2", "Second Parent"); + + // index a child for each parent + String child1name = "First"; + index("child1", parent1.getId(), child1name); + index("child2", parent2.getId(), "Second"); + + elasticsearchTemplate.refresh(ParentEntity.class, true); + elasticsearchTemplate.refresh(ChildEntity.class, true); + + // find all parents that have the first child + QueryBuilder query = hasChildQuery(ParentEntity.CHILD_TYPE, QueryBuilders.fieldQuery("name", child1name)); + List parents = elasticsearchTemplate.queryForList(new NativeSearchQuery(query), ParentEntity.class); + + // we're expecting only the first parent as result + assertThat("parents", parents, contains(hasProperty("id", is(parent1.getId())))); + } + + @Test + public void shouldSearchTopChildrenForGivenParent(){ + // index two parents + ParentEntity parent1 = index("parent1", "First Parent"); + ParentEntity parent2 = index("parent2", "Second Parent"); + + // index a child for each parent + String child1name = "First"; + index("child1", parent1.getId(), child1name); + index("child2", parent2.getId(), "Second"); + + elasticsearchTemplate.refresh(ParentEntity.class, true); + elasticsearchTemplate.refresh(ChildEntity.class, true); + + // find all parents that have the first child using topChildren Query + QueryBuilder query = topChildrenQuery(ParentEntity.CHILD_TYPE, QueryBuilders.fieldQuery("name", child1name)); + List parents = elasticsearchTemplate.queryForList(new NativeSearchQuery(query), ParentEntity.class); + + // we're expecting only the first parent as result + assertThat("parents", parents, contains(hasProperty("id", is(parent1.getId())))); + } + + private ParentEntity index(String parentId, String name) { + ParentEntity parent = new ParentEntity(parentId, name); + IndexQuery index = new IndexQuery(); + index.setId(parent.getId()); + index.setObject(parent); + elasticsearchTemplate.index(index); + + return parent; + } + + private ChildEntity index(String childId, String parentId, String name) { + ChildEntity child = new ChildEntity(childId, parentId, name); + IndexQuery index = new IndexQuery(); + index.setId(child.getId()); + index.setObject(child); + index.setParentId(child.getParentId()); + elasticsearchTemplate.index(index); + + return child; + } +} diff --git a/src/test/java/org/springframework/data/elasticsearch/core/MappingBuilderTests.java b/src/test/java/org/springframework/data/elasticsearch/core/MappingBuilderTests.java index 997e5d74e..ba4b13d58 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/MappingBuilderTests.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/MappingBuilderTests.java @@ -8,6 +8,7 @@ import org.springframework.data.elasticsearch.SampleTransientEntity; import org.springframework.data.elasticsearch.SimpleRecursiveEntity; import org.springframework.data.elasticsearch.StockPrice; import org.springframework.data.elasticsearch.StockPriceBuilder; +import org.springframework.data.elasticsearch.MinimalEntity; import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder; import org.springframework.data.elasticsearch.core.query.SearchQuery; import org.springframework.test.context.ContextConfiguration; @@ -44,7 +45,7 @@ public class MappingBuilderTests { "type\":\"string\",\"index\":\"not_analyzed\",\"search_analyzer\":\"standard\"," + "\"index_analyzer\":\"standard\"}}}}"; - XContentBuilder xContentBuilder = MappingBuilder.buildMapping(SampleTransientEntity.class, "mapping", "id"); + XContentBuilder xContentBuilder = MappingBuilder.buildMapping(SampleTransientEntity.class, "mapping", "id", null); assertThat(xContentBuilder.string(), is(expected)); } @@ -54,7 +55,7 @@ public class MappingBuilderTests { final String expected = "{\"mapping\":{\"properties\":{\"price\":{\"store\":false,\"type\":\"double\"}}}}"; //When - XContentBuilder xContentBuilder = MappingBuilder.buildMapping(StockPrice.class, "mapping", "id"); + XContentBuilder xContentBuilder = MappingBuilder.buildMapping(StockPrice.class, "mapping", "id", null); //Then assertThat(xContentBuilder.string(), is(expected)); @@ -83,4 +84,10 @@ public class MappingBuilderTests { assertThat(entry.getPrice(), is(new BigDecimal(price))); } + @Test + public void shouldCreateMappingForSpecifiedParentType() throws IOException { + final String expected = "{\"mapping\":{\"_parent\":{\"type\":\"parentType\"},\"properties\":{}}}"; + XContentBuilder xContentBuilder = MappingBuilder.buildMapping(MinimalEntity.class, "mapping", "id", "parentType"); + assertThat(xContentBuilder.string(), is(expected)); + } } diff --git a/src/test/java/org/springframework/data/elasticsearch/core/SimpleElasticsearchDateMappingTest.java b/src/test/java/org/springframework/data/elasticsearch/core/SimpleElasticsearchDateMappingTest.java index cc31c0476..e7182526a 100644 --- a/src/test/java/org/springframework/data/elasticsearch/core/SimpleElasticsearchDateMappingTest.java +++ b/src/test/java/org/springframework/data/elasticsearch/core/SimpleElasticsearchDateMappingTest.java @@ -20,7 +20,7 @@ public class SimpleElasticsearchDateMappingTest { @Test public void testCorrectDateMappings() throws NoSuchFieldException, IntrospectionException, IOException { - XContentBuilder xContentBuilder = MappingBuilder.buildMapping(SampleDateMappingEntity.class, "mapping", "id"); + XContentBuilder xContentBuilder = MappingBuilder.buildMapping(SampleDateMappingEntity.class, "mapping", "id", null); Assert.assertEquals(EXPECTED_MAPPING, xContentBuilder.string()); } }