diff --git a/persistence-modules/spring-data-elasticsearch-2/pom.xml b/persistence-modules/spring-data-elasticsearch-2/pom.xml new file mode 100644 index 000000000000..9c0656d03aa1 --- /dev/null +++ b/persistence-modules/spring-data-elasticsearch-2/pom.xml @@ -0,0 +1,212 @@ + + 4.0.0 + spring-data-elasticsearch + spring-data-elasticsearch + jar + + + com.baeldung + parent-boot-3 + 0.0.1-SNAPSHOT + ../../parent-boot-3 + + + + + org.springframework.data + spring-data-elasticsearch + ${spring-data-elasticsearch.version} + + + + + + + + + + + + + + + org.elasticsearch.client + elasticsearch-rest-high-level-client + 7.17.11 + + + org.projectlombok + lombok + ${lombok.version} + + + org.springframework.boot + spring-boot-autoconfigure + + + org.apache.commons + commons-csv + ${commons-csv.version} + + + org.springframework.boot + spring-boot-starter-batch + ${spring-boot.version} + + + + + org.testcontainers + elasticsearch + 1.21.3 + test + + + + + com.squareup.okhttp3 + mockwebserver + test + + + + + org.elasticsearch + elasticsearch + ${elasticsearch.version} + + + + + org.elasticsearch.client + elasticsearch-rest-client + ${elasticsearch.version} + + + + + com.fasterxml.jackson.core + jackson-core + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.boot + spring-boot-starter-logging + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + org.springframework.boot + spring-boot-testcontainers + test + + + + + org.testcontainers + junit-jupiter + ${testcontainers.version} + test + + + + + org.testcontainers + elasticsearch + ${testcontainers.version} + test + + + + + org.testcontainers + testcontainers + ${testcontainers.version} + test + + + + + com.squareup.okhttp3 + mockwebserver + test + + + + + org.assertj + assertj-core + test + + + + + + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + + + + + + + 5.1.2 + 8.9.0 + 1.12.0 + 11 + 11 + 11 + UTF-8 + 8.11.1 + 1.19.3 + + + \ No newline at end of file diff --git a/persistence-modules/spring-data-elasticsearch-2/src/main/java/com/baeldung/Application.java b/persistence-modules/spring-data-elasticsearch-2/src/main/java/com/baeldung/Application.java new file mode 100644 index 000000000000..4ce015efcc23 --- /dev/null +++ b/persistence-modules/spring-data-elasticsearch-2/src/main/java/com/baeldung/Application.java @@ -0,0 +1,15 @@ +package com.baeldung; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; + + +// Exclude DataSource auto-configuration since we're only using Elasticsearch +@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} \ No newline at end of file diff --git a/persistence-modules/spring-data-elasticsearch-2/src/main/java/com/baeldung/wildcardsearch/ElasticsearchConfig.java b/persistence-modules/spring-data-elasticsearch-2/src/main/java/com/baeldung/wildcardsearch/ElasticsearchConfig.java new file mode 100644 index 000000000000..11290b855098 --- /dev/null +++ b/persistence-modules/spring-data-elasticsearch-2/src/main/java/com/baeldung/wildcardsearch/ElasticsearchConfig.java @@ -0,0 +1,32 @@ +package com.baeldung.wildcardsearch; + + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.rest_client.RestClientTransport; +import org.apache.http.HttpHost; +import org.elasticsearch.client.RestClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ElasticsearchConfig { + + @Value("${elasticsearch.host:localhost}") + private String host; + + @Value("${elasticsearch.port:9200}") + private int port; + + @Bean + public RestClient restClient() { + return RestClient.builder(new HttpHost(host, port, "http")).setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder.setConnectTimeout(5000).setSocketTimeout(60000)).setHttpClientConfigCallback(httpClientBuilder -> httpClientBuilder.setMaxConnTotal(100).setMaxConnPerRoute(100)).build(); + } + + @Bean + public ElasticsearchClient elasticsearchClient(RestClient restClient) { + RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper()); + return new ElasticsearchClient(transport); + } +} diff --git a/persistence-modules/spring-data-elasticsearch-2/src/main/java/com/baeldung/wildcardsearch/ElasticsearchWildcardService.java b/persistence-modules/spring-data-elasticsearch-2/src/main/java/com/baeldung/wildcardsearch/ElasticsearchWildcardService.java new file mode 100644 index 000000000000..f7670ce273b6 --- /dev/null +++ b/persistence-modules/spring-data-elasticsearch-2/src/main/java/com/baeldung/wildcardsearch/ElasticsearchWildcardService.java @@ -0,0 +1,205 @@ +package com.baeldung.wildcardsearch; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.SortOrder; +import co.elastic.clients.elasticsearch._types.query_dsl.TextQueryType; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class ElasticsearchWildcardService { + + private static final Logger logger = LoggerFactory.getLogger(ElasticsearchWildcardService.class); + + @Autowired + private ElasticsearchClient elasticsearchClient; + + @Value("${elasticsearch.max-results:1000}") + private int maxResults; + + /** + * Performs wildcard search using the new Java API Client + * Note: For case-insensitive search, the searchTerm is converted to lowercase + * and the field should be mapped with a .keyword subfield or use caseInsensitive flag + */ + public List> wildcardSearch(String indexName, String fieldName, String searchTerm) throws IOException { + logger.info("Performing wildcard search on index: {}, field: {}, term: {}", indexName, fieldName, searchTerm); + + // Convert search term to lowercase for case-insensitive search + String lowercaseSearchTerm = searchTerm.toLowerCase(); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.wildcard(w -> w.field(fieldName).value(lowercaseSearchTerm).caseInsensitive(true))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + public List> wildcardSearchOnKeyword(String indexName, String fieldName, String searchTerm) throws IOException { + logger.info("Performing wildcard search on keyword field - index: {}, field: {}, term: {}", indexName, fieldName, searchTerm); + + // Use the .keyword subfield for exact matching + String keywordField = fieldName + ".keyword"; + + // Convert to lowercase for case-insensitive matching + String lowercaseSearchTerm = searchTerm.toLowerCase(); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.wildcard(w -> w.field(keywordField).value(lowercaseSearchTerm).caseInsensitive(true))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + /** + * Performs prefix search - optimized for autocomplete + */ + public List> prefixSearch(String indexName, String fieldName, String prefix) throws IOException { + logger.info("Performing prefix search on index: {}, field: {}, prefix: {}", indexName, fieldName, prefix); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.prefix(p -> p.field(fieldName).value(prefix))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + /** + * Performs regex search for complex pattern matching + */ + public List> regexpSearch(String indexName, String fieldName, String pattern) throws IOException { + logger.info("Performing regexp search on index: {}, field: {}, pattern: {}", indexName, fieldName, pattern); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.regexp(r -> r.field(fieldName).value(pattern))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + /** + * Performs fuzzy search for typo-tolerant searching + */ + public List> fuzzySearch(String indexName, String fieldName, String searchTerm) throws IOException { + logger.info("Performing fuzzy search on index: {}, field: {}, term: {}", indexName, fieldName, searchTerm); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.fuzzy(f -> f.field(fieldName).value(searchTerm).fuzziness("AUTO"))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + /** + * Performs match phrase prefix search - good for autocomplete + */ + public List> matchPhrasePrefixSearch(String indexName, String fieldName, String searchTerm) throws IOException { + logger.info("Performing match phrase prefix search on index: {}, field: {}, term: {}", indexName, fieldName, searchTerm); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.matchPhrasePrefix(m -> m.field(fieldName).query(searchTerm))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + /** + * Combined wildcard search with multiple conditions + */ + public List> combinedWildcardSearch(String indexName, String field1, String wildcard1, String field2, String wildcard2) throws IOException { + logger.info("Performing combined wildcard search on index: {}", indexName); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.bool(b -> b.must(m -> m.wildcard(w -> w.field(field1).value(wildcard1))).must(m -> m.wildcard(w -> w.field(field2).value(wildcard2))))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + /** + * Case-insensitive wildcard search + */ + public List> caseInsensitiveWildcardSearch(String indexName, String fieldName, String searchTerm) throws IOException { + logger.info("Performing case-insensitive wildcard search on index: {}, field: {}, term: {}", indexName, fieldName, searchTerm); + + String lowercaseSearchTerm = searchTerm.toLowerCase(); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.wildcard(w -> w.field(fieldName + ".lowercase").value(lowercaseSearchTerm).caseInsensitive(true))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + /** + * Advanced wildcard search with filtering and sorting + */ + public List> advancedWildcardSearch(String indexName, String wildcardField, String wildcardTerm, String filterField, String filterValue, String sortField) throws IOException { + logger.info("Performing advanced wildcard search on index: {}", indexName); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.bool(b -> b.must(m -> m.wildcard(w -> w.field(wildcardField).value(wildcardTerm))).filter(f -> f.term(t -> t.field(filterField).value(filterValue))))).sort(so -> so.field(f -> f.field(sortField).order(SortOrder.Asc))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + /** + * Multi-field wildcard search + */ + public List> multiFieldWildcardSearch(String indexName, String searchTerm, String... fields) throws IOException { + logger.info("Performing multi-field wildcard search on index: {}, fields: {}", indexName, String.join(", ", fields)); + + SearchResponse response = elasticsearchClient.search(s -> s.index(indexName).query(q -> q.multiMatch(m -> m.query(searchTerm).fields(List.of(fields)).type(TextQueryType.PhrasePrefix))).size(maxResults), ObjectNode.class); + + return extractSearchResults(response); + } + + /** + * Extract results from SearchResponse + */ + private List> extractSearchResults(SearchResponse response) { + List> results = new ArrayList<>(); + + logger.info("Search completed. Total hits: {}", response.hits().total().value()); + + for (Hit hit : response.hits().hits()) { + Map sourceMap = new HashMap<>(); + + if (hit.source() != null) { + hit.source().fields().forEachRemaining(entry -> { + // Extract the actual value from JsonNode + Object value = extractJsonNodeValue(entry.getValue()); + sourceMap.put(entry.getKey(), value); + }); + } + + results.add(sourceMap); + } + + return results; + } + + /** + * Helper method to extract actual values from JsonNode objects + */ + private Object extractJsonNodeValue(com.fasterxml.jackson.databind.JsonNode jsonNode) { + if (jsonNode == null || jsonNode.isNull()) { + return null; + } else if (jsonNode.isTextual()) { + return jsonNode.asText(); + } else if (jsonNode.isInt()) { + return jsonNode.asInt(); + } else if (jsonNode.isLong()) { + return jsonNode.asLong(); + } else if (jsonNode.isDouble() || jsonNode.isFloat()) { + return jsonNode.asDouble(); + } else if (jsonNode.isBoolean()) { + return jsonNode.asBoolean(); + } else if (jsonNode.isArray()) { + List list = new ArrayList<>(); + jsonNode.elements().forEachRemaining(element -> list.add(extractJsonNodeValue(element))); + return list; + } else if (jsonNode.isObject()) { + Map map = new HashMap<>(); + jsonNode.fields().forEachRemaining(entry -> map.put(entry.getKey(), extractJsonNodeValue(entry.getValue()))); + return map; + } else { + return jsonNode.asText(); + } + } +} \ No newline at end of file diff --git a/persistence-modules/spring-data-elasticsearch-2/src/main/resources/log4j2.properties b/persistence-modules/spring-data-elasticsearch-2/src/main/resources/log4j2.properties new file mode 100644 index 000000000000..fced116e6bbf --- /dev/null +++ b/persistence-modules/spring-data-elasticsearch-2/src/main/resources/log4j2.properties @@ -0,0 +1,6 @@ +appender.console.type = Console +appender.console.name = console +appender.console.layout.type = PatternLayout + +rootLogger.level = info +rootLogger.appenderRef.console.ref = console \ No newline at end of file diff --git a/persistence-modules/spring-data-elasticsearch-2/src/test/java/com/baeldung/wildcardsearch/ElasticsearchWildcardServiceIntegrationTest.java b/persistence-modules/spring-data-elasticsearch-2/src/test/java/com/baeldung/wildcardsearch/ElasticsearchWildcardServiceIntegrationTest.java new file mode 100644 index 000000000000..dafd808df0e6 --- /dev/null +++ b/persistence-modules/spring-data-elasticsearch-2/src/test/java/com/baeldung/wildcardsearch/ElasticsearchWildcardServiceIntegrationTest.java @@ -0,0 +1,271 @@ +package com.baeldung.wildcardsearch; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.IndexRequest; +import co.elastic.clients.elasticsearch.indices.CreateIndexRequest; +import co.elastic.clients.elasticsearch.indices.DeleteIndexRequest; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.elasticsearch.ElasticsearchContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest +@Testcontainers +class ElasticsearchWildcardServiceIntegrationTest { + + private static final Logger logger = LoggerFactory.getLogger(ElasticsearchWildcardServiceIntegrationTest.class); + + @Container + static ElasticsearchContainer elasticsearchContainer = new ElasticsearchContainer( + "docker.elastic.co/elasticsearch/elasticsearch:8.11.1") + .withExposedPorts(9200) + .withEnv("discovery.type", "single-node") + .withEnv("xpack.security.enabled", "false") + .withEnv("xpack.security.http.ssl.enabled", "false"); + + @Autowired + private ElasticsearchWildcardService wildcardService; + + @Autowired + private ElasticsearchClient elasticsearchClient; + + private static final String TEST_INDEX = "test_users"; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("elasticsearch.host", elasticsearchContainer::getHost); + registry.add("elasticsearch.port", () -> elasticsearchContainer.getMappedPort(9200)); + } + + @BeforeEach + void setUp() throws IOException { + // Create test index + createTestIndex(); + + // Index sample documents + indexSampleDocuments(); + + // Wait for documents to be indexed + try { + Thread.sleep(1500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @AfterEach + void cleanup() throws IOException { + // Clean up test index + DeleteIndexRequest deleteRequest = DeleteIndexRequest.of(d -> d + .index(TEST_INDEX) + ); + elasticsearchClient.indices().delete(deleteRequest); + } + + @Test + void testWildcardSearch_IntegrationTest() throws IOException { + // When + List> results = wildcardService.wildcardSearchOnKeyword(TEST_INDEX, "name", "john*"); + + // Then + assertNotNull(results); + assertFalse(results.isEmpty(), "Expected at least one result for 'john*'"); + + logger.info("Found {} results for 'john*'", results.size()); + results.forEach(result -> logger.info("Result: {}", result)); + + // Verify that all results contain names starting with "john" (case-insensitive) + results.forEach(result -> { + Object nameObj = result.get("name"); + assertNotNull(nameObj, "Name field should not be null"); + + String name = nameObj.toString(); + logger.info("Checking name: '{}'", name); + + assertTrue(name.toLowerCase().startsWith("john"), + "Expected name to start with 'john', but got: " + name); + }); + + // Should find exactly 2 documents: "John Doe" and "Johnny Smith" + assertEquals(2, results.size(), "Expected exactly 2 results for 'john*'"); + } + + @Test + void testPrefixSearch_IntegrationTest() throws IOException { + // When + List> results = wildcardService.prefixSearch(TEST_INDEX, "email", "john"); + + // Then + assertNotNull(results); + assertFalse(results.isEmpty()); + + // Verify that all results contain emails starting with "john" + results.forEach(result -> { + Object emailObj = result.get("email"); + assertNotNull(emailObj, "Email field should not be null"); + + String email = emailObj.toString(); + assertTrue(email.startsWith("john"), + "Expected email to start with 'john', but got: " + email); + }); + } + + @Test + void testFuzzySearch_IntegrationTest() throws IOException { + // When - search with typo + List> results = wildcardService.fuzzySearch(TEST_INDEX, "name", "jhon"); + + // Then + assertNotNull(results); + assertFalse(results.isEmpty()); + + // At least one result should contain a name similar to "john" + boolean foundSimilar = results.stream() + .anyMatch(result -> { + Object nameObj = result.get("name"); + if (nameObj == null) return false; + String name = nameObj.toString().toLowerCase(); + return name.contains("john"); + }); + assertTrue(foundSimilar, "Expected to find names similar to 'john'"); + } + + @Test + void testAdvancedWildcardSearch_IntegrationTest() throws IOException { + // When + List> results = wildcardService.advancedWildcardSearch( + TEST_INDEX, "name", "*john*", "status", "active", "name"); + + // Then + assertNotNull(results); + + // Verify filtering worked - all results should have status "active" + results.forEach(result -> { + Object statusObj = result.get("status"); + assertNotNull(statusObj, "Status field should not be null"); + String status = statusObj.toString(); + assertEquals("active", status, + "Expected status to be 'active', but got: " + status); + + Object nameObj = result.get("name"); + assertNotNull(nameObj, "Name field should not be null"); + String name = nameObj.toString(); + assertTrue(name.toLowerCase().contains("john"), + "Expected name to contain 'john', but got: " + name); + }); + } + + @Test + void testWildcardSearch_WithContainsPattern() throws IOException { + // When - search for names containing "John" anywhere + List> results = wildcardService.wildcardSearch(TEST_INDEX, "name", "*john*"); + + // Then + assertNotNull(results); + assertTrue(results.size() >= 2, "Expected at least 2 results"); + + // Verify results contain "john" somewhere in the name + results.forEach(result -> { + Object nameObj = result.get("name"); + assertNotNull(nameObj, "Name field should not be null"); + String name = nameObj.toString(); + assertTrue(name.toLowerCase().contains("john"), + "Expected name to contain 'john', but got: " + name); + }); + } + + @Test + void testMultiFieldWildcardSearch_IntegrationTest() throws IOException { + // When + List> results = wildcardService.multiFieldWildcardSearch( + TEST_INDEX, "john", "name", "email"); + + // Then + assertNotNull(results); + assertFalse(results.isEmpty()); + + // Results should have "john" in either name or email + results.forEach(result -> { + Object nameObj = result.get("name"); + Object emailObj = result.get("email"); + + String name = nameObj != null ? nameObj.toString().toLowerCase() : ""; + String email = emailObj != null ? emailObj.toString().toLowerCase() : ""; + + assertTrue(name.contains("john") || email.contains("john"), + "Expected 'john' in name or email"); + }); + } + + private void createTestIndex() throws IOException { + // Create index with proper mapping for wildcard searches + CreateIndexRequest createRequest = CreateIndexRequest.of(c -> c + .index(TEST_INDEX) + .mappings(m -> m + .properties("name", p -> p + .text(t -> t + .fields("keyword", kf -> kf + .keyword(k -> k) + ) + ) + ) + .properties("email", p -> p + .keyword(k -> k) + ) + .properties("status", p -> p + .keyword(k -> k) + ) + ) + ); + + elasticsearchClient.indices().create(createRequest); + logger.debug("Created test index {} with proper mapping", TEST_INDEX); + } + + private void indexSampleDocuments() throws IOException { + // Create sample documents + Map doc1 = new HashMap<>(); + doc1.put("name", "John Doe"); + doc1.put("email", "john.doe@example.com"); + doc1.put("status", "active"); + + Map doc2 = new HashMap<>(); + doc2.put("name", "Jane Johnson"); + doc2.put("email", "jane.johnson@example.com"); + doc2.put("status", "inactive"); + + Map doc3 = new HashMap<>(); + doc3.put("name", "Johnny Smith"); + doc3.put("email", "johnny.smith@example.com"); + doc3.put("status", "active"); + + // Index documents + indexDocument("1", doc1); + indexDocument("2", doc2); + indexDocument("3", doc3); + } + + private void indexDocument(String id, Map document) throws IOException { + IndexRequest> indexRequest = IndexRequest.of(i -> i + .index(TEST_INDEX) + .id(id) + .document(document) + ); + elasticsearchClient.index(indexRequest); + } +} \ No newline at end of file diff --git a/persistence-modules/spring-data-elasticsearch-2/src/test/java/com/baeldung/wildcardsearch/ElasticsearchWildcardServiceUnitTest.java b/persistence-modules/spring-data-elasticsearch-2/src/test/java/com/baeldung/wildcardsearch/ElasticsearchWildcardServiceUnitTest.java new file mode 100644 index 000000000000..0327d1683388 --- /dev/null +++ b/persistence-modules/spring-data-elasticsearch-2/src/test/java/com/baeldung/wildcardsearch/ElasticsearchWildcardServiceUnitTest.java @@ -0,0 +1,496 @@ +package com.baeldung.wildcardsearch; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import co.elastic.clients.elasticsearch.core.search.HitsMetadata; +import co.elastic.clients.elasticsearch.core.search.TotalHits; +import co.elastic.clients.elasticsearch.core.search.TotalHitsRelation; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * Unit tests for ElasticsearchWildcardService + * These tests use Mockito to mock the ElasticsearchClient - no Docker required! + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("Elasticsearch Wildcard Service Unit Tests") +class ElasticsearchWildcardServiceUnitTest { + + @Mock + private ElasticsearchClient elasticsearchClient; + + @InjectMocks + private ElasticsearchWildcardService wildcardService; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + ReflectionTestUtils.setField(wildcardService, "maxResults", 1000); + } + + // ==================== WILDCARD SEARCH TESTS ==================== + + @Test + @DisplayName("wildcardSearch should return matching documents") + void testWildcardSearch_ReturnsMatchingDocuments() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "John Doe", "john.doe@example.com"), + createHit("2", "Johnny Cash", "johnny.cash@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.wildcardSearch("users", "name", "john*"); + + // Then + assertThat(results).hasSize(2); + assertThat(results.get(0)).containsEntry("name", "John Doe"); + assertThat(results.get(1)).containsEntry("name", "Johnny Cash"); + verify(elasticsearchClient).search(any(Function.class), eq(ObjectNode.class)); + } + + @Test + @DisplayName("wildcardSearch should handle empty results") + void testWildcardSearch_HandlesEmptyResults() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse(); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.wildcardSearch("users", "name", "xyz*"); + + // Then + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("wildcardSearch should be case-insensitive") + void testWildcardSearch_IsCaseInsensitive() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "John Doe", "john.doe@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.wildcardSearch("users", "name", "JOHN*"); + + // Then + assertThat(results).hasSize(1); + assertThat(results.get(0)).containsEntry("name", "John Doe"); + } + + @Test + @DisplayName("wildcardSearch should throw IOException on client failure") + void testWildcardSearch_ThrowsIOException() throws IOException { + // Given + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenThrow(new IOException("Connection timeout")); + + // When & Then + assertThrows(IOException.class, () -> + wildcardService.wildcardSearch("users", "name", "john*")); + } + + // ==================== PREFIX SEARCH TESTS ==================== + + @Test + @DisplayName("prefixSearch should return documents with matching prefix") + void testPrefixSearch_ReturnsMatchingDocuments() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "John Doe", "john@example.com"), + createHit("2", "John Smith", "john.smith@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.prefixSearch("users", "email", "john"); + + // Then + assertThat(results).hasSize(2); + assertThat(results) + .allMatch(r -> r.get("email").toString().startsWith("john")); + } + + @Test + @DisplayName("prefixSearch should return empty list when no matches") + void testPrefixSearch_NoMatches() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse(); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.prefixSearch("users", "email", "xyz"); + + // Then + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("prefixSearch should handle single character prefix") + void testPrefixSearch_SingleCharacterPrefix() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "Alice", "alice@example.com"), + createHit("2", "Andrew", "andrew@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.prefixSearch("users", "name", "a"); + + // Then + assertThat(results).hasSize(2); + } + + // ==================== REGEXP SEARCH TESTS ==================== + + @Test + @DisplayName("regexpSearch should match regex pattern") + void testRegexpSearch_MatchesPattern() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "John Doe", "john.doe@example.com"), + createHit("2", "Jane Doe", "jane.doe@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.regexpSearch("users", "email", ".*@example\\.com"); + + // Then + assertThat(results).hasSize(2); + assertThat(results) + .allMatch(r -> r.get("email").toString().endsWith("@example.com")); + } + + @Test + @DisplayName("regexpSearch should handle complex patterns") + void testRegexpSearch_ComplexPattern() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "User 1", "user123@test.com"), + createHit("2", "User 2", "user456@test.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When - pattern for emails starting with "user" followed by digits + List> results = wildcardService.regexpSearch("users", "email", "user[0-9]+@.*"); + + // Then + assertThat(results).hasSize(2); + } + + @Test + @DisplayName("regexpSearch should return empty for non-matching pattern") + void testRegexpSearch_NoMatches() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse(); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.regexpSearch("users", "email", "nomatch.*"); + + // Then + assertThat(results).isEmpty(); + } + + // ==================== FUZZY SEARCH TESTS ==================== + + @Test + @DisplayName("fuzzySearch should find similar terms with typos") + void testFuzzySearch_FindsSimilarTerms() throws IOException { + // Given - searching for "jhon" should find "john" + SearchResponse mockResponse = createMockResponse( + createHit("1", "John Doe", "john.doe@example.com"), + createHit("2", "Johnny Cash", "johnny.cash@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.fuzzySearch("users", "name", "jhon"); + + // Then + assertThat(results).hasSize(2); + assertThat(results) + .allMatch(r -> r.get("name").toString().toLowerCase().contains("john")); + } + + @Test + @DisplayName("fuzzySearch should handle exact matches") + void testFuzzySearch_ExactMatch() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "John Doe", "john.doe@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.fuzzySearch("users", "name", "john"); + + // Then + assertThat(results).hasSize(1); + assertThat(results.get(0)).containsEntry("name", "John Doe"); + } + + @Test + @DisplayName("fuzzySearch should handle terms too different to match") + void testFuzzySearch_TooManyDifferences() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse(); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.fuzzySearch("users", "name", "zzzzz"); + + // Then + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("fuzzySearch should be tolerant to small spelling mistakes") + void testFuzzySearch_ToleratesSpellingMistakes() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "Michael", "michael@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When - searching for "micheal" (common misspelling) + List> results = wildcardService.fuzzySearch("users", "name", "micheal"); + + // Then + assertThat(results).hasSize(1); + assertThat(results.get(0)).containsEntry("name", "Michael"); + } + + // ==================== ADDITIONAL TEST SCENARIOS ==================== + + @Test + @DisplayName("wildcardSearch should handle multiple wildcards in pattern") + void testWildcardSearch_MultipleWildcards() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "John Michael Doe", "jmdoe@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When - search for names containing both "john" and "doe" + List> results = wildcardService.wildcardSearch("users", "name", "*john*doe*"); + + // Then + assertThat(results).hasSize(1); + } + + @Test + @DisplayName("prefixSearch should work with numeric prefixes") + void testPrefixSearch_NumericPrefix() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "User", "user123@example.com"), + createHit("2", "User", "user124@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.prefixSearch("users", "email", "user12"); + + // Then + assertThat(results).hasSize(2); + } + + @Test + @DisplayName("All search methods should respect maxResults limit") + void testSearchMethods_RespectMaxResults() throws IOException { + // Given - set a low max results + ReflectionTestUtils.setField(wildcardService, "maxResults", 10); + + SearchResponse mockResponse = createMockResponse( + createHit("1", "User 1", "user1@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + wildcardService.wildcardSearch("users", "name", "*"); + + // Then - verify that search was called (maxResults is applied in the query) + verify(elasticsearchClient).search(any(Function.class), eq(ObjectNode.class)); + } + + @Test + @DisplayName("wildcardSearch should handle special characters in search term") + void testWildcardSearch_SpecialCharacters() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "O'Brien", "obrien@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.wildcardSearch("users", "name", "o'*"); + + // Then + assertThat(results).hasSize(1); + assertThat(results.get(0)).containsEntry("name", "O'Brien"); + } + + @Test + @DisplayName("regexpSearch should handle dot metacharacter") + void testRegexpSearch_DotMetacharacter() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "User", "a.b.c@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When - dot needs to be escaped in regex + List> results = wildcardService.regexpSearch("users", "email", ".*\\..*\\..*@.*"); + + // Then + assertThat(results).hasSize(1); + } + + @Test + @DisplayName("fuzzySearch should handle numbers in search terms") + void testFuzzySearch_WithNumbers() throws IOException { + // Given + SearchResponse mockResponse = createMockResponse( + createHit("1", "Room 101", "room101@example.com") + ); + + when(elasticsearchClient.search(any(Function.class), eq(ObjectNode.class))) + .thenReturn(mockResponse); + + // When + List> results = wildcardService.fuzzySearch("users", "name", "room101"); + + // Then + assertThat(results).hasSize(1); + } + + // ==================== HELPER METHODS ==================== + + /** + * Creates a mock SearchResponse with the given hits + */ + @SafeVarargs + private SearchResponse createMockResponse(Hit... hits) { + @SuppressWarnings("unchecked") + SearchResponse mockResponse = mock(SearchResponse.class); + + @SuppressWarnings("unchecked") + HitsMetadata mockHitsMetadata = mock(HitsMetadata.class); + + TotalHits totalHits = TotalHits.of(t -> t + .value(hits.length) + .relation(TotalHitsRelation.Eq) + ); + + when(mockHitsMetadata.hits()).thenReturn(List.of(hits)); + when(mockHitsMetadata.total()).thenReturn(totalHits); + when(mockResponse.hits()).thenReturn(mockHitsMetadata); + + return mockResponse; + } + + /** + * Creates a mock Hit with typical user data + */ + private Hit createHit(String id, String name, String email) { + @SuppressWarnings("unchecked") + Hit mockHit = mock(Hit.class); + + Map sourceData = Map.of( + "name", name, + "email", email, + "status", "active" + ); + + ObjectNode sourceNode = objectMapper.valueToTree(sourceData); + + when(mockHit.id()).thenReturn(id); + when(mockHit.source()).thenReturn(sourceNode); + when(mockHit.score()).thenReturn(1.0); + + return mockHit; + } + + /** + * Creates a mock Hit with custom data + */ + private Hit createHit(String id, Map sourceData) { + @SuppressWarnings("unchecked") + Hit mockHit = mock(Hit.class); + + ObjectNode sourceNode = objectMapper.valueToTree(sourceData); + + when(mockHit.id()).thenReturn(id); + when(mockHit.source()).thenReturn(sourceNode); + when(mockHit.score()).thenReturn(1.0); + + return mockHit; + } +} \ No newline at end of file diff --git a/persistence-modules/spring-data-elasticsearch-2/src/test/resources/products.csv b/persistence-modules/spring-data-elasticsearch-2/src/test/resources/products.csv new file mode 100644 index 000000000000..bfbf67d6ae92 --- /dev/null +++ b/persistence-modules/spring-data-elasticsearch-2/src/test/resources/products.csv @@ -0,0 +1,26 @@ +id,name,category,price,stock +1,Microwave,Appliances,705.77,136 +2,Vacuum Cleaner,Appliances,1397.23,92 +3,Power Bank,Accessories,114.78,32 +4,Keyboard,Accessories,54.09,33 +5,Charger,Accessories,157.95,90 +6,Microwave,Appliances,239.81,107 +7,Power Bank,Accessories,933.47,118 +8,Washer,Appliances,298.55,41 +9,Camera,Electronics,1736.29,148 +10,Laptop,Electronics,632.69,18 +11,Smartwatch,Electronics,261.04,129 +12,Tablet,Electronics,774.85,115 +13,Laptop,Electronics,58.03,93 +14,Smartwatch,Electronics,336.71,63 +15,Washer,Appliances,975.68,148 +16,Charger,Accessories,1499.18,98 +17,Tablet,Electronics,89.61,147 +18,Laptop,Electronics,251.67,80 +19,Washer,Appliances,1026.93,102 +20,Power Bank,Accessories,1239.02,30 +21,Camera,Electronics,1990.1,92 +22,Headphones,Accessories,1532.08,112 +23,Refrigerator,Appliances,205.95,77 +24,Vacuum Cleaner,Appliances,218.43,43 +25,Vacuum Cleaner,Appliances,1869.89,123