diff --git a/README.md b/README.md index 002290f..5d41519 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Companion code for a series of posts on [ankurm.com](https://ankurm.com) coverin |---|---|---| | [`time-series/`](./time-series) | Time-Series Data in Java: QuestDB vs InfluxDB vs TimescaleDB | QuestDB 10.0.1, InfluxDB 3 Core 3.12.0, TimescaleDB 2.25.1 (PG17) | | [`mongodb/`](./mongodb) | Spring Boot 4 with MongoDB: Spring Data MongoDB, Aggregations and Transactions | MongoDB 9.0.2 single-node replica set, Spring Data MongoDB | -| `elasticsearch/` | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9, Spring Data Elasticsearch | +| [`elasticsearch/`](./elasticsearch) | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9.4.8, Spring Data Elasticsearch 6.1.1 | | `distributed-cache/` | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast, Infinispan, Redis | Each module has its own `pom.xml`, a `docker-compose.yml` for its backing services, and a `docs/output/` directory holding the real, re-runnable transcripts that every number quoted in its post traces back to. There are no narrative doc chapters here — the deeper material for each post lives in accordions inside the post itself. diff --git a/elasticsearch/docs/output/27-category-facets.txt b/elasticsearch/docs/output/27-category-facets.txt new file mode 100644 index 0000000..30982ee --- /dev/null +++ b/elasticsearch/docs/output/27-category-facets.txt @@ -0,0 +1,2 @@ +query="desk" facet counts by category: + furniture=2 diff --git a/elasticsearch/docs/output/27-fuzzy-search.txt b/elasticsearch/docs/output/27-fuzzy-search.txt new file mode 100644 index 0000000..3326529 --- /dev/null +++ b/elasticsearch/docs/output/27-fuzzy-search.txt @@ -0,0 +1,4 @@ +query="wireles" (misspelled) max_price=1000.00 + Wireless Mechanical Keyboard ($89.99, electronics) + Wireless Earbuds Pro ($129.0, electronics) + Ergonomic Office Chair ($259.0, furniture) diff --git a/elasticsearch/docs/output/27-price-filter.txt b/elasticsearch/docs/output/27-price-filter.txt new file mode 100644 index 0000000..8b28ef0 --- /dev/null +++ b/elasticsearch/docs/output/27-price-filter.txt @@ -0,0 +1,2 @@ +query="wireless" max_price=100.00 + Wireless Mechanical Keyboard ($89.99) diff --git a/elasticsearch/docs/output/27-relevance-boost.txt b/elasticsearch/docs/output/27-relevance-boost.txt new file mode 100644 index 0000000..74f230b --- /dev/null +++ b/elasticsearch/docs/output/27-relevance-boost.txt @@ -0,0 +1,4 @@ +query="wireless" max_price=1000.00 (ranked by relevance) + Wireless Mechanical Keyboard + Wireless Earbuds Pro + Ergonomic Office Chair diff --git a/elasticsearch/pom.xml b/elasticsearch/pom.xml new file mode 100644 index 0000000..55feb49 --- /dev/null +++ b/elasticsearch/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + elasticsearch-demo + 1.0.0 + elasticsearch-demo + + + 25 + 2.0.5 + + + + + + org.testcontainers + testcontainers-bom + ${testcontainers.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-data-elasticsearch + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.boot + spring-boot-testcontainers + test + + + org.testcontainers + testcontainers-elasticsearch + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/elasticsearch/src/main/java/com/ankurm/elasticsearch/ElasticsearchDemoApplication.java b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ElasticsearchDemoApplication.java new file mode 100644 index 0000000..33d37ce --- /dev/null +++ b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ElasticsearchDemoApplication.java @@ -0,0 +1,11 @@ +package com.ankurm.elasticsearch; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class ElasticsearchDemoApplication { + public static void main(String[] args) { + SpringApplication.run(ElasticsearchDemoApplication.class, args); + } +} diff --git a/elasticsearch/src/main/java/com/ankurm/elasticsearch/Product.java b/elasticsearch/src/main/java/com/ankurm/elasticsearch/Product.java new file mode 100644 index 0000000..57c37e7 --- /dev/null +++ b/elasticsearch/src/main/java/com/ankurm/elasticsearch/Product.java @@ -0,0 +1,28 @@ +package com.ankurm.elasticsearch; + +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Field; +import org.springframework.data.elasticsearch.annotations.FieldType; + +import java.math.BigDecimal; +import java.util.List; + +/** + * name and description are mapped "text" so Elasticsearch analyzes and + * tokenizes them for full-text matching; category is mapped "keyword" so + * it sorts, filters and aggregates on the exact string, not analyzed + * tokens. Mapping a field the wrong way is the single most common + * Elasticsearch mistake, and it fails silently - a "keyword" field still + * accepts text, it just won't match partial words, and a "text" field + * still accepts a filter, it just won't filter exactly. + */ +@Document(indexName = "products") +public record Product( + @Id String id, + @Field(type = FieldType.Text) String name, + @Field(type = FieldType.Text) String description, + @Field(type = FieldType.Keyword) String category, + @Field(type = FieldType.Double) BigDecimal price, + @Field(type = FieldType.Keyword) List tags) { +} diff --git a/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductRepository.java b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductRepository.java new file mode 100644 index 0000000..fddb121 --- /dev/null +++ b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductRepository.java @@ -0,0 +1,9 @@ +package com.ankurm.elasticsearch; + +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +import java.util.List; + +public interface ProductRepository extends ElasticsearchRepository { + List findByCategory(String category); +} diff --git a/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductSearchService.java b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductSearchService.java new file mode 100644 index 0000000..4d8e882 --- /dev/null +++ b/elasticsearch/src/main/java/com/ankurm/elasticsearch/ProductSearchService.java @@ -0,0 +1,86 @@ +package com.ankurm.elasticsearch; + +import co.elastic.clients.elasticsearch._types.aggregations.Aggregation; +import co.elastic.clients.elasticsearch._types.query_dsl.Query; +import org.springframework.data.elasticsearch.client.elc.NativeQuery; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.springframework.data.elasticsearch.core.SearchHit; +import org.springframework.data.elasticsearch.core.SearchHits; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * ElasticsearchRepository covers point lookups and exact-field derived + * queries; relevance-ranked full-text search with filters and facets + * needs the query DSL, built here with the elasticsearch-java client's + * own type-safe builders rather than hand-written JSON strings. + */ +@Service +public class ProductSearchService { + + private final ElasticsearchOperations operations; + + public ProductSearchService(ElasticsearchOperations operations) { + this.operations = operations; + } + + /** + * Multi-field search with per-field relevance boosting (a match in the + * name counts for more than the same match in the description) and + * fuzziness so a typo like "wireles" still finds "wireless". The price + * ceiling is a filter, not part of the relevance query - filters narrow + * the result set without affecting how matches are scored. + */ + public List search(String term, BigDecimal maxPrice) { + Query textQuery = Query.of(q -> q.multiMatch(m -> m + .query(term) + .fields("name^2", "description") + .fuzziness("AUTO"))); + + Query priceFilter = Query.of(q -> q.range(r -> r + .number(n -> n.field("price").lte(maxPrice.doubleValue())))); + + Query combined = Query.of(q -> q.bool(b -> b + .must(textQuery) + .filter(priceFilter))); + + NativeQuery query = NativeQuery.builder().withQuery(combined).build(); + SearchHits hits = operations.search(query, Product.class); + return hits.getSearchHits().stream().map(SearchHit::getContent).toList(); + } + + /** + * A terms aggregation over the (unanalyzed) category keyword field - + * the facet counts a storefront sidebar shows next to each category + * name, computed by Elasticsearch over the matching set, not pulled + * back and counted client-side. + */ + public Map categoryFacets(String term) { + Query textQuery = Query.of(q -> q.multiMatch(m -> m + .query(term) + .fields("name^2", "description") + .fuzziness("AUTO"))); + + NativeQuery query = NativeQuery.builder() + .withQuery(textQuery) + .withAggregation("by_category", Aggregation.of(a -> a + .terms(t -> t.field("category")))) + .withMaxResults(0) + .build(); + + SearchHits hits = operations.search(query, Product.class); + Map facets = new java.util.LinkedHashMap<>(); + var aggregations = hits.getAggregations(); + if (aggregations != null) { + var elc = (org.springframework.data.elasticsearch.client.elc.ElasticsearchAggregations) aggregations; + var byCategory = elc.get("by_category").aggregation().getAggregate().sterms(); + for (var bucket : byCategory.buckets().array()) { + facets.put(bucket.key().stringValue(), bucket.docCount()); + } + } + return facets; + } +} diff --git a/elasticsearch/src/test/java/com/ankurm/elasticsearch/ProductSearchTest.java b/elasticsearch/src/test/java/com/ankurm/elasticsearch/ProductSearchTest.java new file mode 100644 index 0000000..9b5bff3 --- /dev/null +++ b/elasticsearch/src/test/java/com/ankurm/elasticsearch/ProductSearchTest.java @@ -0,0 +1,119 @@ +package com.ankurm.elasticsearch; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.data.elasticsearch.core.ElasticsearchOperations; +import org.testcontainers.elasticsearch.ElasticsearchContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Runs the search, filter and aggregation behaviour against a real + * Elasticsearch 9.4.8 node (matching the elasticsearch-java client + * version 9.4.5 that spring-boot-starter-data-elasticsearch 4.1.1 + * actually resolves - verified with mvn dependency:tree, not assumed). + * Every number in the post's transcripts comes from these tests. + */ +@Testcontainers +@SpringBootTest +class ProductSearchTest { + + @Container + @ServiceConnection + static ElasticsearchContainer elasticsearch = + new ElasticsearchContainer("elasticsearch:9.4.8") + .withEnv("xpack.security.enabled", "false"); + + @Autowired ProductRepository repository; + @Autowired ProductSearchService searchService; + @Autowired ElasticsearchOperations operations; + + @BeforeEach + void seedCatalog() { + if (operations.indexOps(Product.class).exists()) { + operations.indexOps(Product.class).delete(); + } + operations.indexOps(Product.class).createWithMapping(); + + repository.saveAll(List.of( + new Product(null, "Wireless Mechanical Keyboard", "A compact 65% keyboard with hot-swappable switches", "electronics", new BigDecimal("89.99"), List.of("keyboard", "wireless")), + new Product(null, "Wireless Earbuds Pro", "Noise-cancelling earbuds with a 30-hour battery case", "electronics", new BigDecimal("129.00"), List.of("audio", "wireless")), + new Product(null, "USB-C Charging Cable", "Braided cable rated for fast charging", "electronics", new BigDecimal("12.50"), List.of("cable")), + new Product(null, "Oak Standing Desk", "Electric height-adjustable desk with a solid oak top", "furniture", new BigDecimal("449.00"), List.of("desk", "office")), + new Product(null, "Ergonomic Office Chair", "Mesh-back chair with lumbar support and wireless lumbar adjustment motor", "furniture", new BigDecimal("259.00"), List.of("chair", "office")), + new Product(null, "Desk Lamp", "LED desk lamp with adjustable color temperature", "furniture", new BigDecimal("34.00"), List.of("lamp")))); + + operations.indexOps(Product.class).refresh(); + } + + @Test + void fuzzySearchFindsAMisspelledTerm() { + List results = searchService.search("wireles", new BigDecimal("1000")); + Transcript t = new Transcript("27-fuzzy-search.txt"); + t.line("query=\"wireles\" (misspelled) max_price=1000.00"); + for (Product p : results) { + t.line(" %s ($%s, %s)", p.name(), p.price(), p.category()); + } + t.write(); + + assertThat(results).extracting(Product::name) + .contains("Wireless Mechanical Keyboard", "Wireless Earbuds Pro"); + } + + @Test + void nameMatchOutranksDescriptionOnlyMatch() { + // "Wireless" appears in two product NAMES and once only in a + // description (the chair's lumbar motor) - the ^2 boost on name + // should put both name matches ahead of the description-only one. + List results = searchService.search("wireless", new BigDecimal("1000")); + Transcript t = new Transcript("27-relevance-boost.txt"); + t.line("query=\"wireless\" max_price=1000.00 (ranked by relevance)"); + for (Product p : results) { + t.line(" %s", p.name()); + } + t.write(); + + List names = results.stream().map(Product::name).toList(); + int keyboardRank = names.indexOf("Wireless Mechanical Keyboard"); + int earbudsRank = names.indexOf("Wireless Earbuds Pro"); + int chairRank = names.indexOf("Ergonomic Office Chair"); + assertThat(keyboardRank).isGreaterThanOrEqualTo(0); + assertThat(earbudsRank).isGreaterThanOrEqualTo(0); + assertThat(chairRank).isGreaterThan(Math.max(keyboardRank, earbudsRank)); + } + + @Test + void priceFilterExcludesMatchesOverTheCeiling() { + List underBudget = searchService.search("wireless", new BigDecimal("100")); + Transcript t = new Transcript("27-price-filter.txt"); + t.line("query=\"wireless\" max_price=100.00"); + for (Product p : underBudget) { + t.line(" %s ($%s)", p.name(), p.price()); + } + t.write(); + + assertThat(underBudget).extracting(Product::name) + .contains("Wireless Mechanical Keyboard") + .doesNotContain("Wireless Earbuds Pro"); + } + + @Test + void categoryFacetsCountMatchesPerCategory() { + Map facets = searchService.categoryFacets("desk"); + Transcript t = new Transcript("27-category-facets.txt"); + t.line("query=\"desk\" facet counts by category:"); + facets.forEach((category, count) -> t.line(" %s=%d", category, count)); + t.write(); + + assertThat(facets).containsEntry("furniture", 2L); + } +} diff --git a/elasticsearch/src/test/java/com/ankurm/elasticsearch/Transcript.java b/elasticsearch/src/test/java/com/ankurm/elasticsearch/Transcript.java new file mode 100644 index 0000000..f4bdb86 --- /dev/null +++ b/elasticsearch/src/test/java/com/ankurm/elasticsearch/Transcript.java @@ -0,0 +1,40 @@ +package com.ankurm.elasticsearch; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Writes docs/output/NN-*.txt while a test asserts the same numbers, so + * every figure quoted in the blog post is backed by an assertion that + * fails the build when it stops being true. + */ +final class Transcript { + private final List lines = new ArrayList<>(); + private final Path file; + + Transcript(String name) { + this.file = Path.of("docs/output/" + name); + } + + Transcript line(String s) { + lines.add(s); + System.out.println(s); + return this; + } + + Transcript line(String fmt, Object... args) { + return line(fmt.formatted(args)); + } + + void write() { + try { + Files.createDirectories(file.getParent()); + Files.writeString(file, String.join("\n", lines) + "\n"); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +}