diff --git a/src/main/java/com/vincent/es/repository/StudentEsRepository.java b/src/main/java/com/vincent/es/repository/StudentEsRepository.java index ddba9e9..3c79b97 100644 --- a/src/main/java/com/vincent/es/repository/StudentEsRepository.java +++ b/src/main/java/com/vincent/es/repository/StudentEsRepository.java @@ -8,15 +8,18 @@ import co.elastic.clients.elasticsearch.core.bulk.BulkOperation; import co.elastic.clients.elasticsearch.core.bulk.BulkResponseItem; import co.elastic.clients.elasticsearch.core.bulk.CreateOperation; +import co.elastic.clients.elasticsearch.core.search.Hit; import co.elastic.clients.elasticsearch.indices.CreateIndexRequest; import co.elastic.clients.elasticsearch.indices.DeleteIndexRequest; import com.vincent.es.entity.Student; import com.vincent.es.util.IOSupplier; +import com.vincent.es.util.SearchInfo; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; public class StudentEsRepository { private final ElasticsearchClient client; @@ -120,6 +123,26 @@ public void deleteById(String id) { execute(() -> client.delete(request)); } + public List find(SearchInfo info) { + var request = new SearchRequest.Builder() + .index(indexName) + .query(info.toQuery()) + .sort(info.getSortOptions()) + .from(info.getFrom()) + .size(info.getSize()) + .build(); + + return execute(() -> { + var searchResponse = client.search(request, Student.class); + return searchResponse + .hits() + .hits() + .stream() + .map(Hit::source) + .collect(Collectors.toList()); + }); + } + private Map getPropertyMappings() { var englishIssuedDateProperty = DateProperty.of(b -> b)._toProperty(); return Map.of("englishIssuedDate", englishIssuedDateProperty); diff --git a/src/main/java/com/vincent/es/util/SampleData.java b/src/main/java/com/vincent/es/util/SampleData.java new file mode 100644 index 0000000..5f1622e --- /dev/null +++ b/src/main/java/com/vincent/es/util/SampleData.java @@ -0,0 +1,16 @@ +package com.vincent.es.util; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.vincent.es.entity.Student; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +public class SampleData { + public static List get() throws IOException { + var file = new File("students.json"); + return new ObjectMapper().readValue(file, new TypeReference<>() {}); + } +} diff --git a/src/main/java/com/vincent/es/util/SearchInfo.java b/src/main/java/com/vincent/es/util/SearchInfo.java new file mode 100644 index 0000000..d155647 --- /dev/null +++ b/src/main/java/com/vincent/es/util/SearchInfo.java @@ -0,0 +1,63 @@ +package com.vincent.es.util; + +import co.elastic.clients.elasticsearch._types.SortOptions; +import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; + +import java.util.List; + +public class SearchInfo { + private BoolQuery boolQuery; // 查詢條件 + private List sortOptions = List.of(); // 排序方式 + private Integer from; // 資料的跳過數量 + private Integer size; // 資料的擷取數量 + + public static SearchInfo of(BoolQuery bool) { + var info = new SearchInfo(); + info.boolQuery = bool; + + return info; + } + + public static SearchInfo of(Query query) { + var bool = BoolQuery.of(b -> b.filter(query)); + return of(bool); + } + + public BoolQuery getBoolQuery() { + return boolQuery; + } + + public void setBoolQuery(BoolQuery boolQuery) { + this.boolQuery = boolQuery; + } + + public List getSortOptions() { + return sortOptions; + } + + public void setSortOptions(List sortOptions) { + this.sortOptions = sortOptions; + } + + public Integer getFrom() { + return from; + } + + public void setFrom(Integer from) { + this.from = from; + } + + public Integer getSize() { + return size; + } + + public void setSize(Integer size) { + this.size = size; + } + + // library 使用 Query 類別當作條件的傳遞介面 + public Query toQuery() { + return boolQuery._toQuery(); + } +} diff --git a/src/main/java/com/vincent/es/util/SearchUtils.java b/src/main/java/com/vincent/es/util/SearchUtils.java new file mode 100644 index 0000000..750a3bd --- /dev/null +++ b/src/main/java/com/vincent/es/util/SearchUtils.java @@ -0,0 +1,191 @@ +package com.vincent.es.util; + +import co.elastic.clients.elasticsearch._types.*; +import co.elastic.clients.elasticsearch._types.query_dsl.*; +import co.elastic.clients.json.JsonData; + +import java.util.Collection; +import java.util.Date; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class SearchUtils { + private SearchUtils() {} + + /** + *
+     *     {
+     *         "term": {
+     *             "{@param field}": {@param value}
+     *         }
+     *     }
+     * 
+ */ + public static Query createTermQuery(String field, Object value) { + var builder = new TermQuery.Builder(); + if (value instanceof Integer) { + builder + .field(field) + .value((int) value); // 此方法接受 long 型態 + } else if (value instanceof String){ + builder + .field(field + ".keyword") + .value((String) value); + } else { + throw new UnsupportedOperationException("Please implement for other type additionally."); + } + + return builder.build()._toQuery(); + } + + /** + *
+     *     {
+     *         "terms": {
+     *             "{@param field}": [
+     *                 {@param values[0]},
+     *                 {@param values[1]}, ...
+     *             ]
+     *         }
+     *     }
+     * 
+ */ + public static Query createTermsQuery(String field, Collection values) { + var elem = values.stream().findAny().orElseThrow(); + + Stream fieldValueStream; + if (elem instanceof Integer) { + fieldValueStream = values.stream() + .map(value -> FieldValue.of(b -> b.longValue((int) value))); + } else if (elem instanceof String) { + fieldValueStream = values.stream() + .map(value -> FieldValue.of(b -> b.stringValue((String) value))); + } else { + throw new UnsupportedOperationException("Please implement for other type additionally."); + } + + var fieldValues = fieldValueStream.collect(Collectors.toList()); + var termsQueryField = TermsQueryField.of(b -> b.value(fieldValues)); + + return new TermsQuery.Builder() + .field(field) + .terms(termsQueryField) + .build() + ._toQuery(); + } + + /** + *
+     *     {
+     *         "range": {
+     *             "{@param field}": {
+     *                 "gte": "{@param gte}",
+     *                 "lte": "{@param lte}"
+     *             }
+     *         }
+     *     }
+     * 
+ */ + public static Query createRangeQuery(String field, Number gte, Number lte) { + var builder = new RangeQuery.Builder().field(field); + + if (gte != null) { + builder.gte(JsonData.of(gte)); + } + + if (lte != null) { + builder.lte(JsonData.of(lte)); + } + + return builder.build()._toQuery(); + } + + public static Query createRangeQuery(String field, Date gte, Date lte) { + var builder = new RangeQuery.Builder().field(field); + + if (gte != null) { + builder.gte(JsonData.of(gte)); + } + + if (lte != null) { + builder.lte(JsonData.of(lte)); + } + + return builder.build()._toQuery(); + } + + /** + *
+     *     {
+     *         "bool": {
+     *             "should": [
+     *                 {
+     *                     "match": {
+     *                         "{@param fields[0]}": "{@param searchText}"
+     *                     }
+     *                 },
+     *                 {
+     *                     "match": {
+     *                         "{@param fields[1]}": "{@param searchText}"
+     *                     }
+     *                 }, ...
+     *             ]
+     *         }
+     *     }
+     * 
+ */ + public static Query createMatchQuery(Set fields, String searchText) { + var bool = new BoolQuery.Builder(); + fields.stream() + .map(field -> { + var matchQuery = new MatchQuery.Builder() + .field(field) + .query(searchText) + .build(); + return matchQuery._toQuery(); + }) + .forEach(bool::should); + + return bool.build()._toQuery(); + } + + /** + *
+     *     {
+     *         "exists": {
+     *             "field": {@param field}
+     *         }
+     *     }
+     * 
+ */ + public static Query createFieldExistsQuery(String field) { + return new ExistsQuery.Builder() + .field(field) + .build() + ._toQuery(); + } + + /** + *
+     *     {
+     *         "{@param field}": {
+     *             "order": {@param order},
+     *             "mode": {@param mode}
+     *         }
+     *     }
+     * 
+ */ + public static SortOptions createSortOption(String field, SortOrder order, SortMode mode) { + var fieldSort = new FieldSort.Builder() + .field(field) + .order(order) + .mode(mode) + .build(); + return SortOptions.of(b -> b.field(fieldSort)); + } + + public static SortOptions createSortOption(String field, SortOrder order) { + return createSortOption(field, order, null); + } +} diff --git a/src/test/java/com/vincent/es/ApplicationTests.java b/src/test/java/com/vincent/es/ApplicationTests.java deleted file mode 100644 index 31d8ca4..0000000 --- a/src/test/java/com/vincent/es/ApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.vincent.es; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class ApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/vincent/es/SearchTests.java b/src/test/java/com/vincent/es/SearchTests.java new file mode 100644 index 0000000..289962b --- /dev/null +++ b/src/test/java/com/vincent/es/SearchTests.java @@ -0,0 +1,178 @@ +package com.vincent.es; + +import co.elastic.clients.elasticsearch._types.SortMode; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery; +import co.elastic.clients.elasticsearch._types.query_dsl.MatchAllQuery; +import com.vincent.es.entity.Student; +import com.vincent.es.repository.StudentEsRepository; +import com.vincent.es.util.SampleData; +import com.vincent.es.util.SearchInfo; +import com.vincent.es.util.SearchUtils; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.junit4.SpringRunner; + +import java.io.IOException; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(SpringRunner.class) +@SpringBootTest +public class SearchTests { + + @Autowired + private StudentEsRepository repository; + + @SuppressWarnings({"squid:S2925"}) + @Before + public void setup() throws IOException, InterruptedException { + repository.init(); + + var documents = SampleData.get(); + repository.insert(documents); + Thread.sleep(2000); + } + + @Test + public void testTerm_NumberField() { + var query = SearchUtils.createTermQuery("grade", 3); + var searchInfo = SearchInfo.of(query); + + var students = repository.find(searchInfo); + + // Mario + assertDocumentIds(true, students, "102"); + } + + @Test + public void testTerms_TextField() { + var values = List.of("資訊管理", "企業管理"); + var query = SearchUtils.createTermsQuery("departments.keyword", values); + var searchInfo = SearchInfo.of(query); + + var students = repository.find(searchInfo); + + // Vincent, Winnie + assertDocumentIds(true, students, "103", "104"); + } + + @Test + public void testNumberRange() { + var query = SearchUtils.createRangeQuery("grade", 2, 4); + var searchInfo = SearchInfo.of(query); + + var students = repository.find(searchInfo); + + // Dora, Mario, Vincent + assertDocumentIds(true, students, "101", "102", "103"); + } + + @Test + public void testDateRange() throws ParseException { + var sdf = new SimpleDateFormat("yyyy-MM-dd"); + var fromDate = sdf.parse("2021-07-01"); + var toDate = sdf.parse("2022-06-30"); + + var query = SearchUtils.createRangeQuery("englishIssuedDate", fromDate, toDate); + var searchInfo = SearchInfo.of(query); + + var students = repository.find(searchInfo); + + // Winnie, Mario + assertDocumentIds(true, students, "102", "104"); + } + + @Test + public void testFullTextSearch() { + var fields = Set.of("name", "introduction"); + var searchText = "vincent career"; + + var query = SearchUtils.createMatchQuery(fields, searchText); + var bool = BoolQuery.of(b -> b.must(query)); + var searchInfo = SearchInfo.of(bool); + + var students = repository.find(searchInfo); + + // Winnie, Vincent + assertDocumentIds(true, students, "103", "104"); + } + + @Test + public void testTextFieldExists() { + var query = SearchUtils.createFieldExistsQuery("bloodType"); + var searchInfo = SearchInfo.of(query); + + var students = repository.find(searchInfo); + + // Dora, Mario, Vincent + assertDocumentIds(true, students, "101", "102", "103"); + } + + @Test + public void testTextArrayFieldExists() { + var query = SearchUtils.createFieldExistsQuery("phoneNumbers"); + var searchInfo = SearchInfo.of(query); + + var students = repository.find(searchInfo); + + // Dora, Vincent + assertDocumentIds(true, students, "101", "103"); + } + + @Test + public void testSortByMultipleFields() { + var coursePointSort = SearchUtils.createSortOption("courses.point", SortOrder.Desc, SortMode.Max); + var nameSort = SearchUtils.createSortOption("name.keyword", SortOrder.Asc); + + var query = MatchAllQuery.of(b -> b)._toQuery(); + + var searchInfo = SearchInfo.of(query); + searchInfo.setSortOptions(List.of(coursePointSort, nameSort)); + + var students = repository.find(searchInfo); + + // Mario -> Vincent -> Dora -> Winnie + assertDocumentIds(false, students, "102", "103", "101", "104"); + } + + @Test + public void testPaging() { + var gradeSort = SearchUtils.createSortOption("grade", SortOrder.Desc); + + var query = MatchAllQuery.of(b -> b)._toQuery(); + + var searchInfo = SearchInfo.of(query); + searchInfo.setSortOptions(List.of(gradeSort)); + searchInfo.setFrom(0); + searchInfo.setSize(2); + + var students = repository.find(searchInfo); + + // Dora -> Mario + assertDocumentIds(false, students, "101", "102"); + } + + private void assertDocumentIds(boolean ignoreOrder, List actualDocs, String... expectedIdArray) { + var expectedIds = List.of(expectedIdArray); + var actualIds = actualDocs.stream() + .map(Student::getId) + .collect(Collectors.toList()); + + if (ignoreOrder) { + assertTrue(expectedIds.containsAll(actualIds)); + assertTrue(actualIds.containsAll(expectedIds)); + } else { + assertEquals(expectedIds, actualIds); + } + } +} diff --git a/students.json b/students.json new file mode 100644 index 0000000..3e29421 --- /dev/null +++ b/students.json @@ -0,0 +1,84 @@ +[ + { + "id": "103", + "name": "Vincent Zheng", + "departments": ["資訊管理", "財務金融"], + "courses": [ + { "name": "計算機概論", "point": 3 }, + { "name": "程式設計", "point": 4 }, + { "name": "投資學", "point": 3 }, + { "name": "會計學", "point": 0 } + ], + "grade": 2, + "conductScore": 86, + "job": { + "name": "班長", + "primary": true + }, + "introduction": "I have a blog used to record what I learn in my career. All of them are about information technology and programming.", + "englishIssuedDate": "2021-01-01", + "bloodType": "A", + "phoneNumbers": [ + "0988123456", + "0224123456" + ] + }, + { + "id": "101", + "name": "Dora Pan", + "departments": ["財務金融"], + "courses": [ + { "name": "財金概論", "point": 3 }, + { "name": "保險學", "point": 3 }, + { "name": "投資學", "point": 3 } + ], + "grade": 4, + "conductScore": 74, + "job": { + "name": "衛生股長", + "primary": null + }, + "introduction": "Wealth ignores those who ignore it. So I apply knowledge about accounting in my life.", + "englishIssuedDate": "2021-04-01", + "bloodType": "B", + "phoneNumbers": [ + "0912345678" + ] + }, + { + "id": "104", + "name": "Winnie Kuo", + "departments": ["企業管理"], + "courses": [ + { "name": "會計學", "point": 3 }, + { "name": "商業概論", "point": 1 } + ], + "grade": 1, + "conductScore": 71, + "job": { + "name": "班長", + "primary": false + }, + "introduction": "To lead a team in company in career, learn to lead students in university first.", + "englishIssuedDate": "2021-12-01", + "phoneNumbers": [] + }, + { + "id": "102", + "name": "Mario Lu", + "departments": ["會計"], + "courses": [ + { "name": "會計學", "point": 5 }, + { "name": "審計學", "point": 3 }, + { "name": "企業資源規劃", "point": 3 } + ], + "grade": 3, + "conductScore": 83, + "job": { + "name": "康樂股長" + }, + "introduction": "Accounting work can be done by technology. So I start to learn programming on internet.", + "englishIssuedDate": "2022-05-01", + "bloodType": "O" + } +] \ No newline at end of file