mongodb: Spring Data MongoDB transactions, aggregation and compound index (post 26)
This commit is contained in:
@@ -5,7 +5,7 @@ Companion code for a series of posts on [ankurm.com](https://ankurm.com) coverin
|
|||||||
| Module | Post | Stack |
|
| Module | Post | Stack |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [`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) |
|
| [`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/` | Spring Boot 4 with MongoDB: Spring Data MongoDB, Aggregations and Transactions | MongoDB 8 replica set, Spring Data MongoDB |
|
| [`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/` | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9, Spring Data Elasticsearch |
|
||||||
| `distributed-cache/` | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast, Infinispan, Redis |
|
| `distributed-cache/` | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast, Infinispan, Redis |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
query on indexed fields (customerId, createdAt): winning plan stage = FETCH IXSCAN
|
||||||
|
query on unindexed field (total): winning plan stage = COLLSCAN
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
order_id=6acfd210bf211a8919681ce9 status=PLACED total=199.90
|
||||||
|
inventory_before=50 inventory_after=40 (decremented by order quantity)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
attempted_quantity=999 available=5 -> Insufficient stock for WIDGET-2: available=5 requested=999
|
||||||
|
inventory_before=5 inventory_after=5 (unchanged - transaction rolled back)
|
||||||
|
orders_collection_count=0 (no order document was inserted)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
status=PLACED total_revenue=350.00 order_count=2
|
||||||
|
status=REJECTED_INSUFFICIENT_STOCK total_revenue=40.00 order_count=1
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.1</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>mongodb-demo</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
<maven.compiler.release>25</maven.compiler.release>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-testcontainers</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-mongodb</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-junit-jupiter</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<dependencyManagement>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.testcontainers</groupId>
|
||||||
|
<artifactId>testcontainers-bom</artifactId>
|
||||||
|
<version>2.0.5</version>
|
||||||
|
<type>pom</type>
|
||||||
|
<scope>import</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
</dependencyManagement>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.annotation.Version;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stock for one SKU in one warehouse. @Version gives optimistic locking on
|
||||||
|
* top of the multi-document transaction used when placing an order - see
|
||||||
|
* OrderService.placeOrder().
|
||||||
|
*/
|
||||||
|
@Document("inventory")
|
||||||
|
public record InventoryItem(@Id String id, String sku, String warehouse, int quantityOnHand, @Version Long version) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface InventoryRepository extends MongoRepository<InventoryItem, String> {
|
||||||
|
Optional<InventoryItem> findBySkuAndWarehouse(String sku, String warehouse);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.mongodb.MongoDatabaseFactory;
|
||||||
|
import org.springframework.data.mongodb.MongoTransactionManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Boot does NOT auto-configure a PlatformTransactionManager for
|
||||||
|
* MongoDB the way it does for JDBC - without this bean, @Transactional on
|
||||||
|
* OrderService is a silent no-op (no error, no rollback, just two
|
||||||
|
* independent writes). See the post's transactions section.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class MongoConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
MongoTransactionManager transactionManager(MongoDatabaseFactory dbFactory) {
|
||||||
|
return new MongoTransactionManager(dbFactory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
@SpringBootApplication
|
||||||
|
public class MongoDemoApplication {
|
||||||
|
public static void main(String[] args) { SpringApplication.run(MongoDemoApplication.class, args); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.index.CompoundIndex;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An order document. The compound index on (customerId, createdAt) is what
|
||||||
|
* turns "this customer's recent orders" from a collection scan into an
|
||||||
|
* index scan - see the post's "indexes" section for the explain() output
|
||||||
|
* that proves it.
|
||||||
|
*/
|
||||||
|
@Document("orders")
|
||||||
|
@CompoundIndex(name = "customer_created_idx", def = "{'customerId': 1, 'createdAt': -1}")
|
||||||
|
public record Order(
|
||||||
|
@Id String id,
|
||||||
|
String customerId,
|
||||||
|
List<OrderItem> items,
|
||||||
|
OrderStatus status,
|
||||||
|
BigDecimal total,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
public record OrderItem(String sku, int quantity, BigDecimal unitPrice) {}
|
||||||
|
|
||||||
|
public enum OrderStatus { PLACED, REJECTED_INSUFFICIENT_STOCK }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
|
||||||
|
import org.springframework.data.mongodb.core.MongoTemplate;
|
||||||
|
import org.springframework.data.mongodb.core.aggregation.Aggregation;
|
||||||
|
import org.springframework.data.mongodb.core.aggregation.AggregationResults;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.springframework.data.mongodb.core.aggregation.Aggregation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived queries and @Query cover point lookups; an aggregation pipeline
|
||||||
|
* is for reshaping many documents into a report MongoRepository has no
|
||||||
|
* vocabulary for - here, revenue and order count per status.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class OrderAnalytics {
|
||||||
|
|
||||||
|
private final MongoTemplate mongoTemplate;
|
||||||
|
|
||||||
|
public OrderAnalytics(MongoTemplate mongoTemplate) {
|
||||||
|
this.mongoTemplate = mongoTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record RevenueByStatus(String status, BigDecimal totalRevenue, long orderCount) {}
|
||||||
|
|
||||||
|
public List<RevenueByStatus> revenueByStatus() {
|
||||||
|
Aggregation pipeline = newAggregation(
|
||||||
|
group("status")
|
||||||
|
.sum("total").as("totalRevenue")
|
||||||
|
.count().as("orderCount"),
|
||||||
|
project("totalRevenue", "orderCount").and("status").previousOperation(),
|
||||||
|
sort(org.springframework.data.domain.Sort.Direction.DESC, "totalRevenue")
|
||||||
|
);
|
||||||
|
AggregationResults<RevenueByStatusRaw> results =
|
||||||
|
mongoTemplate.aggregate(pipeline, "orders", RevenueByStatusRaw.class);
|
||||||
|
return results.getMappedResults().stream()
|
||||||
|
.map(r -> new RevenueByStatus(r.status(), r.totalRevenue(), r.orderCount()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
record RevenueByStatusRaw(String status, BigDecimal totalRevenue, long orderCount) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface OrderRepository extends MongoRepository<Order, String> {
|
||||||
|
|
||||||
|
// Derived query - served by the customerId/createdAt compound index.
|
||||||
|
List<Order> findByCustomerIdOrderByCreatedAtDesc(String customerId);
|
||||||
|
|
||||||
|
List<Order> findByStatus(Order.OrderStatus status);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placing an order touches two collections - inventory (decrement stock)
|
||||||
|
* and orders (insert the order) - and both must succeed or neither does.
|
||||||
|
* Without the transaction, a crash between the two writes leaves inventory
|
||||||
|
* decremented with no order to show for it.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final OrderRepository orders;
|
||||||
|
private final InventoryRepository inventory;
|
||||||
|
|
||||||
|
public OrderService(OrderRepository orders, InventoryRepository inventory) {
|
||||||
|
this.orders = orders;
|
||||||
|
this.inventory = inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Order placeOrder(String customerId, String warehouse, List<Order.OrderItem> items) {
|
||||||
|
for (Order.OrderItem item : items) {
|
||||||
|
InventoryItem stock = inventory.findBySkuAndWarehouse(item.sku(), warehouse)
|
||||||
|
.orElseThrow(() -> new IllegalStateException("No stock record for " + item.sku()));
|
||||||
|
if (stock.quantityOnHand() < item.quantity()) {
|
||||||
|
// Throwing here rolls back any inventory decrements already
|
||||||
|
// applied earlier in this loop, in the same transaction.
|
||||||
|
throw new InsufficientStockException(item.sku(), stock.quantityOnHand(), item.quantity());
|
||||||
|
}
|
||||||
|
inventory.save(new InventoryItem(stock.id(), stock.sku(), stock.warehouse(),
|
||||||
|
stock.quantityOnHand() - item.quantity(), stock.version()));
|
||||||
|
}
|
||||||
|
|
||||||
|
BigDecimal total = items.stream()
|
||||||
|
.map(i -> i.unitPrice().multiply(BigDecimal.valueOf(i.quantity())))
|
||||||
|
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||||
|
|
||||||
|
return orders.save(new Order(null, customerId, items, Order.OrderStatus.PLACED, total, Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class InsufficientStockException extends RuntimeException {
|
||||||
|
public InsufficientStockException(String sku, int available, int requested) {
|
||||||
|
super("Insufficient stock for %s: available=%d requested=%d".formatted(sku, available, requested));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
|
||||||
|
import com.mongodb.client.MongoCollection;
|
||||||
|
import org.bson.Document;
|
||||||
|
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.mongodb.core.MongoTemplate;
|
||||||
|
import org.testcontainers.containers.MongoDBContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the repository, aggregation, transaction and index behaviour
|
||||||
|
* against a real MongoDB 9.0.2 single-node replica set (Testcontainers'
|
||||||
|
* MongoDBContainer configures replica-set mode automatically - no
|
||||||
|
* .withReplicaSet() call needed on this version, unlike what older
|
||||||
|
* tutorials show). Every number in the post's transcripts comes from
|
||||||
|
* these tests.
|
||||||
|
*/
|
||||||
|
@Testcontainers
|
||||||
|
@SpringBootTest
|
||||||
|
class MongoDemoTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
@ServiceConnection
|
||||||
|
static MongoDBContainer mongo = new MongoDBContainer("mongo:9.0.2");
|
||||||
|
|
||||||
|
@Autowired OrderRepository orders;
|
||||||
|
@Autowired InventoryRepository inventory;
|
||||||
|
@Autowired OrderService orderService;
|
||||||
|
@Autowired OrderAnalytics analytics;
|
||||||
|
@Autowired MongoTemplate mongoTemplate;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void cleanDatabase() {
|
||||||
|
mongoTemplate.getDb().drop();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transactionCommitsBothWritesTogether() {
|
||||||
|
inventory.save(new InventoryItem(null, "WIDGET-1", "blr-wh", 50, null));
|
||||||
|
|
||||||
|
Order placed = orderService.placeOrder("cust-1", "blr-wh",
|
||||||
|
List.of(new Order.OrderItem("WIDGET-1", 10, new BigDecimal("19.99"))));
|
||||||
|
|
||||||
|
InventoryItem afterOrder = inventory.findBySkuAndWarehouse("WIDGET-1", "blr-wh").orElseThrow();
|
||||||
|
|
||||||
|
Transcript t = new Transcript("26-order-transaction-commit.txt");
|
||||||
|
t.line("order_id=%s status=%s total=%s", placed.id(), placed.status(), placed.total());
|
||||||
|
t.line("inventory_before=50 inventory_after=%d (decremented by order quantity)", afterOrder.quantityOnHand());
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertEquals(Order.OrderStatus.PLACED, placed.status());
|
||||||
|
assertEquals(40, afterOrder.quantityOnHand());
|
||||||
|
assertEquals(1, orders.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void transactionRollsBackBothWritesOnInsufficientStock() {
|
||||||
|
inventory.save(new InventoryItem(null, "WIDGET-2", "blr-wh", 5, null));
|
||||||
|
|
||||||
|
OrderService.InsufficientStockException ex = assertThrows(OrderService.InsufficientStockException.class,
|
||||||
|
() -> orderService.placeOrder("cust-2", "blr-wh",
|
||||||
|
List.of(new Order.OrderItem("WIDGET-2", 999, new BigDecimal("5.00")))));
|
||||||
|
|
||||||
|
InventoryItem afterFailedOrder = inventory.findBySkuAndWarehouse("WIDGET-2", "blr-wh").orElseThrow();
|
||||||
|
|
||||||
|
Transcript t = new Transcript("26-order-transaction-rollback.txt");
|
||||||
|
t.line("attempted_quantity=999 available=5 -> %s", ex.getMessage());
|
||||||
|
t.line("inventory_before=5 inventory_after=%d (unchanged - transaction rolled back)", afterFailedOrder.quantityOnHand());
|
||||||
|
t.line("orders_collection_count=%d (no order document was inserted)", orders.count());
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertEquals(5, afterFailedOrder.quantityOnHand());
|
||||||
|
assertEquals(0, orders.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aggregationGroupsRevenueByStatus() {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
orders.saveAll(List.of(
|
||||||
|
new Order(null, "cust-3", List.of(), Order.OrderStatus.PLACED, new BigDecimal("100.00"), now),
|
||||||
|
new Order(null, "cust-4", List.of(), Order.OrderStatus.PLACED, new BigDecimal("250.00"), now),
|
||||||
|
new Order(null, "cust-5", List.of(), Order.OrderStatus.REJECTED_INSUFFICIENT_STOCK, new BigDecimal("40.00"), now)
|
||||||
|
));
|
||||||
|
|
||||||
|
List<OrderAnalytics.RevenueByStatus> result = analytics.revenueByStatus();
|
||||||
|
|
||||||
|
Transcript t = new Transcript("26-revenue-aggregation.txt");
|
||||||
|
for (OrderAnalytics.RevenueByStatus r : result) {
|
||||||
|
t.line("status=%s total_revenue=%s order_count=%d", r.status(), r.totalRevenue(), r.orderCount());
|
||||||
|
}
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertEquals(2, result.size());
|
||||||
|
OrderAnalytics.RevenueByStatus placed = result.stream()
|
||||||
|
.filter(r -> r.status().equals("PLACED")).findFirst().orElseThrow();
|
||||||
|
assertEquals(new BigDecimal("350.00"), placed.totalRevenue());
|
||||||
|
assertEquals(2, placed.orderCount());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void compoundIndexTurnsTheQueryIntoAnIndexScan() {
|
||||||
|
// Explicit, rather than relying on spring.data.mongodb.auto-index-creation
|
||||||
|
// (false by default) - see the post's indexes section.
|
||||||
|
mongoTemplate.indexOps(Order.class).createIndex(
|
||||||
|
new org.springframework.data.mongodb.core.index.CompoundIndexDefinition(
|
||||||
|
new Document("customerId", 1).append("createdAt", -1)).named("customer_created_idx"));
|
||||||
|
|
||||||
|
Instant now = Instant.now();
|
||||||
|
for (int i = 0; i < 500; i++) {
|
||||||
|
orders.save(new Order(null, "cust-" + (i % 5), List.of(), Order.OrderStatus.PLACED,
|
||||||
|
BigDecimal.valueOf(i), now.minusSeconds(i)));
|
||||||
|
}
|
||||||
|
|
||||||
|
MongoCollection<Document> raw = mongoTemplate.getCollection("orders");
|
||||||
|
|
||||||
|
Document indexedExplain = raw.find(new Document("customerId", "cust-0")
|
||||||
|
.append("createdAt", new Document("$lte", now)))
|
||||||
|
.sort(new Document("createdAt", -1))
|
||||||
|
.explain();
|
||||||
|
String indexedStage = winningPlanStage(indexedExplain);
|
||||||
|
|
||||||
|
Document unindexedExplain = raw.find(new Document("total", new Document("$gt", 490))).explain();
|
||||||
|
String unindexedStage = winningPlanStage(unindexedExplain);
|
||||||
|
|
||||||
|
Transcript t = new Transcript("26-index-explain.txt");
|
||||||
|
t.line("query on indexed fields (customerId, createdAt): winning plan stage = %s", indexedStage);
|
||||||
|
t.line("query on unindexed field (total): winning plan stage = %s", unindexedStage);
|
||||||
|
t.write();
|
||||||
|
|
||||||
|
assertTrue(indexedStage.contains("IXSCAN"), "expected an index scan, got: " + indexedStage);
|
||||||
|
assertTrue(unindexedStage.contains("COLLSCAN"), "expected a collection scan, got: " + unindexedStage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private static String winningPlanStage(Document explain) {
|
||||||
|
Document queryPlanner = (Document) explain.get("queryPlanner");
|
||||||
|
Document winningPlan = (Document) queryPlanner.get("winningPlan");
|
||||||
|
StringBuilder stages = new StringBuilder();
|
||||||
|
Document stage = winningPlan;
|
||||||
|
while (stage != null) {
|
||||||
|
stages.append(stage.getString("stage")).append(" ");
|
||||||
|
stage = (Document) stage.get("inputStage");
|
||||||
|
}
|
||||||
|
return stages.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ankurm.mongodb;
|
||||||
|
|
||||||
|
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<String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user