From 2245eb7b1c15315fb38fd7925b6b060d9e2b53b0 Mon Sep 17 00:00:00 2001 From: Rayhan Hossain Date: Tue, 11 Aug 2026 15:41:09 -0700 Subject: [PATCH] Add Spring Data + Document DB Playground Signed-off-by: Rayhan Hossain --- README.md | 20 +- playgrounds/spring-data-mongodb/README.md | 298 ++++++++++++++++++ .../spring-data-mongodb/app/.gitignore | 2 + playgrounds/spring-data-mongodb/app/pom.xml | 56 ++++ .../com/example/playground/Application.java | 15 + .../playground/CrudCompatibilityTest.java | 262 +++++++++++++++ .../playground/config/MongoConfig.java | 24 ++ .../com/example/playground/model/Book.java | 121 +++++++ .../playground/repository/BookRepository.java | 13 + .../support/MongoClientFactory.java | 83 +++++ .../playground/support/MongoUriSupport.java | 58 ++++ .../playground/web/BookController.java | 114 +++++++ .../playground/web/HealthController.java | 34 ++ .../src/main/resources/application.properties | 13 + .../spring-data-mongodb/scripts/lib.sh | 123 ++++++++ .../spring-data-mongodb/scripts/run-app.sh | 35 ++ .../spring-data-mongodb/scripts/run-test.sh | 33 ++ .../scripts/start-documentdb.sh | 14 + .../scripts/stop-documentdb.sh | 9 + 19 files changed, 1322 insertions(+), 5 deletions(-) create mode 100644 playgrounds/spring-data-mongodb/README.md create mode 100644 playgrounds/spring-data-mongodb/app/.gitignore create mode 100644 playgrounds/spring-data-mongodb/app/pom.xml create mode 100644 playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/Application.java create mode 100644 playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/CrudCompatibilityTest.java create mode 100644 playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/config/MongoConfig.java create mode 100644 playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/model/Book.java create mode 100644 playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/repository/BookRepository.java create mode 100644 playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/support/MongoClientFactory.java create mode 100644 playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/support/MongoUriSupport.java create mode 100644 playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/web/BookController.java create mode 100644 playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/web/HealthController.java create mode 100644 playgrounds/spring-data-mongodb/app/src/main/resources/application.properties create mode 100755 playgrounds/spring-data-mongodb/scripts/lib.sh create mode 100755 playgrounds/spring-data-mongodb/scripts/run-app.sh create mode 100755 playgrounds/spring-data-mongodb/scripts/run-test.sh create mode 100755 playgrounds/spring-data-mongodb/scripts/start-documentdb.sh create mode 100755 playgrounds/spring-data-mongodb/scripts/stop-documentdb.sh diff --git a/README.md b/README.md index 9e1dd43..dbf1bd9 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ playground's README states what it needs. | [beanie](playgrounds/beanie/) | Python — Beanie ODM | FastAPI REST API + a CRUD/compatibility test suite using the Beanie ODM. | | [pymongo](playgrounds/pymongo/) | Python — PyMongo driver | Flask REST API + a CRUD/compatibility test suite using the raw PyMongo driver. | | [benchmarks](playgrounds/benchmarks/) | Shared + adapters | Repeatable performance experiments, including local latency analysis. | +| [spring-data-mongodb](playgrounds/spring-data-mongodb/) | Java — Spring Data MongoDB | Spring Boot REST API + a CRUD/compatibility test suite using Spring Data MongoDB. | More MongoDB driver playgrounds are planned. Contributions are welcome. @@ -63,6 +64,14 @@ cd playgrounds/pymongo ./scripts/run-app.sh # or run the demo REST API ``` +To try the Spring Data MongoDB playground: + +```bash +cd playgrounds/spring-data-mongodb +./scripts/run-test.sh # start DocumentDB locally and run the compatibility suite +./scripts/run-app.sh # or run the demo REST API +``` + ## Shared telemetry demo [`shared/telemetry/`](shared/telemetry/) provides a reusable local stack with a @@ -94,11 +103,12 @@ documentdb-playground/ ├── shared/ │ └── telemetry/ # Collector + Jaeger + tracing-enabled DocumentDB stack └── playgrounds/ - ├── benchmarks/ # Performance experiments + driver adapters - ├── mongoose/ # Node.js + Mongoose ODM - ├── mongodb-node/ # Node.js + MongoDB native driver - ├── beanie/ # Python + Beanie ODM - └── pymongo/ # Python + PyMongo driver + ├── benchmarks/ # Performance experiments + driver adapters + ├── mongoose/ # Node.js + Mongoose ODM + ├── mongodb-node/ # Node.js + MongoDB native driver + ├── beanie/ # Python + Beanie ODM + ├── pymongo/ # Python + PyMongo driver + └── spring-data-mongodb/ # Java + Spring Data MongoDB ``` ## License diff --git a/playgrounds/spring-data-mongodb/README.md b/playgrounds/spring-data-mongodb/README.md new file mode 100644 index 0000000..1e60455 --- /dev/null +++ b/playgrounds/spring-data-mongodb/README.md @@ -0,0 +1,298 @@ +# Spring Data MongoDB with DocumentDB (local) + +This playground shows how to use [Spring Data MongoDB](https://spring.io/projects/spring-data-mongodb) +— the Spring ecosystem's MongoDB abstraction for **Java** — against DocumentDB, +running **entirely on your machine**. It is the JVM counterpart to the Node.js +(Mongoose / native driver) and Python (Beanie / PyMongo) playgrounds. It +includes: + +- a small **Spring Boot + Spring Data MongoDB REST API** (`app/`), and +- a standalone **CRUD/compatibility test suite** + ([`CrudCompatibilityTest`](app/src/main/java/com/example/playground/CrudCompatibilityTest.java)) + that exercises connect, index creation, insert, query, update, aggregation, + unique-index enforcement, delete, and vector search. + +There is **no Kubernetes and no cloud**. DocumentDB runs as the +[`documentdb-local`](https://github.com/documentdb/documentdb) emulator in a +single Docker container, and the app/test run as local JVM processes (via Maven) +that connect straight to it. + +> **What is Spring Data MongoDB?** It is a **Java library** that layers +> repositories (`MongoRepository`) and a template API (`MongoTemplate`) over the +> official MongoDB Java driver. Your application defines `@Document` classes and +> repository interfaces; Spring Data generates queries and maps documents to +> objects. Here it is used by the demo **app** +> ([`BookController`](app/src/main/java/com/example/playground/web/BookController.java)) +> and the standalone **test** +> ([`CrudCompatibilityTest`](app/src/main/java/com/example/playground/CrudCompatibilityTest.java)). + +## Architecture + +Everything is local. The emulator container exposes the MongoDB wire protocol on +`localhost:10260`; the JVM processes connect to it directly. + +``` + Your machine (WSL / Linux / macOS) +┌──────────────────────────────────────────────────────────────────┐ +│ ┌────────────────────┐ ┌──────────────────────────────┐ │ +│ │ spring-data app / │ TLS, │ documentdb-local (Docker) │ │ +│ │ test (Java + JVM) │ wire │ ┌────────────┐ ┌─────────┐ │ │ +│ │ Spring Data + │────────▶│ │ Gateway │▶│Postgres │ │ │ +│ │ MongoDB Java driver│ :10260 │ │ (10260) │ │ (engine)│ │ │ +│ └────────────────────┘ │ └────────────┘ └─────────┘ │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +## Prerequisites + +- **Docker** (to run the `documentdb-local` emulator) +- **JDK 17+** (Spring Boot 3 requires Java 17 or newer) +- **Maven** (a system `mvn`; the scripts also use `./mvnw` if you add a wrapper) + +On Windows, run these from a **WSL** shell. + +## Quick Start + +From this directory (`playgrounds/spring-data-mongodb/`). `run-test.sh` and +`run-app.sh` are **two independent operations** — each starts DocumentDB on its +own if it isn't already running. + +### Option A — run the test suite + +```bash +# Run the full CRUD/compatibility suite end-to-end. +# Starts DocumentDB in Docker (first run pulls the image), then runs the tests. +./scripts/run-test.sh +``` + +Expected output: + +``` +Spring Data MongoDB DocumentDB compatibility test +================================================= + ✅ connect + ✅ create indexes + ✅ insert (single) + ✅ insert (many) + ✅ findById + ✅ find with filter + sort + limit + ✅ count + ✅ updateFirst ($set) + ✅ findAndModify (returns new) + ✅ aggregation ($unwind/$group/$sort) + ✅ unique index enforcement (duplicate sku rejected) + ✅ delete (single) + ✅ vector index + insert (cosmosSearch vector-ivf) + ✅ $vectorSearch returns nearest neighbor + ✅ vector cleanup (drop collection) + ✅ cleanup (drop collection) +================================================= +Passed: 16 Failed: 0 +``` + +### Option B — run the demo REST API + +```bash +# Starts DocumentDB (if not already running) and serves the API on :3000. +# This stays in the foreground until you press Ctrl-C. +./scripts/run-app.sh +``` + +Stop the database when you are done: + +```bash +./scripts/stop-documentdb.sh +``` + +Set `KEEP_DB=0` when running the suite to remove the container automatically: + +```bash +KEEP_DB=0 ./scripts/run-test.sh +``` + +The API uses the `springdata_demo` database and the suite uses +`springdata_test`, so they can run at the same time. + +## Trying the API + +With `./scripts/run-app.sh` running, the API is on `http://localhost:3000`: + +```bash +# Health +curl -s http://localhost:3000/health +# {"status":"healthy","db":"connected"} + +# Create a book +curl -s -X POST http://localhost:3000/books \ + -H 'Content-Type: application/json' \ + -d '{"title":"Dune","author":"Herbert","genres":["sci-fi"],"pages":412,"rating":5}' + +# List books +curl -s http://localhost:3000/books | jq . +curl -s 'http://localhost:3000/books?author=Herbert' | jq . + +# Count books per genre (aggregation) +curl -s http://localhost:3000/stats/genres | jq . +``` + +The API also provides `GET`, `PATCH`, and `DELETE /books/{id}`. + +## Connecting Spring Data to DocumentDB + +The DocumentDB gateway speaks the MongoDB wire protocol but advertises itself as +a **standalone** server over **TLS** (with a self-signed cert). Spring Data uses +the MongoDB Java driver underneath, so the playground supplies its own +`MongoClient` built from a sanitized connection string plus TLS settings (see +[`MongoConfig`](app/src/main/java/com/example/playground/config/MongoConfig.java) +and [`MongoClientFactory`](app/src/main/java/com/example/playground/support/MongoClientFactory.java)): + +```java +@Bean +MongoClient mongoClient() { + return MongoClientFactory.fromEnv(); // sanitizes the URI + trusts the local cert +} +``` + +Two things differ from the sibling playgrounds because of how the **Java** driver +handles TLS: + +- **Self-signed certificate.** Node.js/Python accept it with + `tlsAllowInvalidCertificates=true`. The Java driver has no connection-string + option that bypasses certificate-chain validation (its `tlsInsecure` only + disables *hostname* verification). So, when `TLS_INSECURE` is not `false`, the + playground installs a trust-all `SSLContext` and allows invalid hostnames in + `MongoClientSettings` — the Java equivalent of the other drivers' option. +- **`replicaSet` is stripped**, because it conflicts with `directConnection=true` + against the standalone gateway. + +The connection string built by the scripts is: + +``` +mongodb://:@localhost:10260/?tls=true&tlsAllowInvalidCertificates=true&directConnection=true +``` + +The app strips `tlsAllowInvalidCertificates` (handled by the trust-all +`SSLContext` instead) and `replicaSet`, connecting with: + +``` +mongodb://:@localhost:10260/?tls=true&directConnection=true +``` + +For production against a real (non-emulator) deployment, set `TLS_INSECURE=false` +and configure a truststore containing the server's CA instead of trusting all +certificates. + +## Configuration Reference + +All settings are passed via environment variables; there is no secrets file. + +### Emulator + scripts (`scripts/`) + +Read by [`lib.sh`](scripts/lib.sh) and the `start`/`stop`/`run` scripts. + +| Variable | Default | Description | +| ---------------------- | -------------------------------------------------------- | -------------------------------------------------------- | +| `DOCUMENTDB_IMAGE` | `ghcr.io/documentdb/documentdb/documentdb-local:latest` | Emulator image to pull/run. | +| `DOCUMENTDB_CONTAINER` | `documentdb-local` | Docker container name. | +| `DOCUMENTDB_HOST` | `localhost` | Host the app/test connect to. | +| `DOCUMENTDB_PORT` | `10260` | Host port mapped to the gateway. | +| `DOCUMENTDB_USERNAME` | `docdbadmin` | Emulator admin username. **Do not use `documentdb`** (reserved — the gateway rejects it as "Username is invalid"). | +| `DOCUMENTDB_PASSWORD` | `Documentdb!Local1` | Emulator admin password. If you use special characters, URL-encode them in the connection string. | +| `KEEP_DB` | `1` | `run-test.sh` only: set `0` to remove the container after tests. | +| `PORT` | `3000` | Local port the Spring Boot app listens on (`run-app.sh`). | + +### App + test (`app/`) + +Read by [`MongoConfig`](app/src/main/java/com/example/playground/config/MongoConfig.java), +[`MongoClientFactory`](app/src/main/java/com/example/playground/support/MongoClientFactory.java), +and [`CrudCompatibilityTest`](app/src/main/java/com/example/playground/CrudCompatibilityTest.java). +The scripts set `MONGO_URI` for you from the variables above. + +| Variable | Default | Description | +| -------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------ | +| `MONGO_URI` | _(set by scripts)_ | DocumentDB connection string. `replicaSet` and `tlsAllowInvalidCertificates` are stripped automatically (the local cert is trusted via an SSLContext). The test also accepts it as the first CLI argument. | +| `MONGO_DB` | `springdata_demo` (app), `springdata_test` (test)| Database name Spring Data connects to. | +| `TLS_INSECURE` | `true` | When `true`, trusts the self-signed cert and allows invalid hostnames. Set `false` for CA-verified TLS. | +| `PORT` | `3000` | Port the Spring Boot API listens on. | + +## Running the Suite Manually + +The scripts handle everything, but you can also run the suite directly against +any reachable DocumentDB connection string: + +```bash +cd app +MONGO_URI='mongodb://docdbadmin:Documentdb!Local1@localhost:10260/?tls=true&tlsAllowInvalidCertificates=true&directConnection=true' \ + mvn -q compile exec:java +``` + +## DocumentDB Compatibility Notes + +| Spring Data feature | Status | Notes | +| -------------------------------------------- | ------------- | --------------------------------------------------------------------- | +| CRUD (`insert`/`find`/`save`/`remove`) | ✅ Supported | Standard `MongoTemplate` and repository operations work as expected. | +| `findById` / `_id` point lookups | ✅ Supported | Works on the current `documentdb-local:latest` image. | +| Index creation (`auto-index-creation`, `IndexOperations`) | ✅ Supported | Built asynchronously by the engine. Avoid `collation`. | +| Unique indexes | ✅ Supported | Duplicate keys raise Spring's `DuplicateKeyException` (driver code `11000`). | +| `findAndModify` (returns new) | ✅ Supported | `FindAndModifyOptions.returnNew(true)` returns the updated document. | +| Aggregation pipelines | ✅ Common stages | `$match`, `$group`, `$unwind`, `$sort`, etc. Atlas-only stages differ. | +| `$vectorSearch` / vector search | ✅ Supported | DocumentDB supports a `cosmosSearch` vector index (e.g. `vector-ivf`) queried via the `$vectorSearch` stage. The suite creates one and runs a nearest-neighbor query. | +| Index `collation` | ❌ Not supported | `createIndex.collation is not implemented yet`; omit it. | +| Transactions / change streams | ⚠️ Not covered | The local gateway advertises standalone topology; verify before relying on them. | + +## Troubleshooting + +### `Cannot reach the Docker daemon` + +Start Docker Desktop (or your Docker daemon) and try again. + +### `Maven is required` / `java (JDK 17+) is required` + +Install a JDK 17+ and Maven, then re-run. The scripts prefer a project wrapper +(`./mvnw`) if present, otherwise a system `mvn`. + +### Connection timeouts + +- Confirm the container is up: `docker ps --filter name=documentdb-local`. +- Inspect readiness: `docker logs documentdb-local`. +- The port is bound to `127.0.0.1`; ensure your `MONGO_URI` host is `localhost` + and the port is `10260`. + +### TLS handshake / certificate errors + +The local emulator uses a self-signed certificate. Keep `TLS_INSECURE=true` +(the default) for local runs; the app maps it to the Java driver's +`tlsInsecure` option. + +### `createIndex.collation is not implemented yet` + +An index uses `collation`. Remove it; DocumentDB does not implement collation +indexes. The models here intentionally avoid it. + +## Directory Layout + +``` +spring-data-mongodb/ +├── README.md +├── app/ +│ ├── pom.xml +│ └── src/main/ +│ ├── java/com/example/playground/ +│ │ ├── Application.java # Spring Boot entry point +│ │ ├── config/MongoConfig.java # MongoClient (DocumentDB options) +│ │ ├── support/MongoUriSupport.java # URI sanitizer (drops replicaSet/tls opts) +│ │ ├── support/MongoClientFactory.java # MongoClient + trust-all SSLContext +│ │ ├── model/Book.java # @Document model + compound index +│ │ ├── repository/BookRepository.java +│ │ ├── web/BookController.java # REST API (/books, /stats) +│ │ ├── web/HealthController.java # /health +│ │ └── CrudCompatibilityTest.java # Standalone compatibility suite +│ └── resources/application.properties +└── scripts/ + ├── lib.sh # Shared container lifecycle + connection-string builder + ├── start-documentdb.sh + ├── stop-documentdb.sh + ├── run-app.sh + └── run-test.sh +``` diff --git a/playgrounds/spring-data-mongodb/app/.gitignore b/playgrounds/spring-data-mongodb/app/.gitignore new file mode 100644 index 0000000..e97c6ee --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/.gitignore @@ -0,0 +1,2 @@ +target/ +*.class diff --git a/playgrounds/spring-data-mongodb/app/pom.xml b/playgrounds/spring-data-mongodb/app/pom.xml new file mode 100644 index 0000000..6665d1d --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/pom.xml @@ -0,0 +1,56 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.example + documentdb-spring-data-playground + 1.0.0 + documentdb-spring-data-playground + Spring Boot + Spring Data MongoDB demo and CRUD test suite running against DocumentDB + + + 17 + + com.example.playground.CrudCompatibilityTest + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-mongodb + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.example.playground.Application + + + + org.codehaus.mojo + exec-maven-plugin + 3.1.0 + + ${exec.mainClass} + + + + + diff --git a/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/Application.java b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/Application.java new file mode 100644 index 0000000..f12d9ec --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/Application.java @@ -0,0 +1,15 @@ +package com.example.playground; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.mongodb.config.EnableMongoAuditing; + +/** Entry point for the Spring Boot + Spring Data MongoDB demo API. */ +@SpringBootApplication +@EnableMongoAuditing +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/CrudCompatibilityTest.java b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/CrudCompatibilityTest.java new file mode 100644 index 0000000..ca3c861 --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/CrudCompatibilityTest.java @@ -0,0 +1,262 @@ +package com.example.playground; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import com.example.playground.support.MongoClientFactory; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoCollection; +import org.bson.Document; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.annotation.Id; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +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.data.mongodb.core.index.Index; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; + +/** + * Standalone Spring Data MongoDB CRUD/compatibility suite for DocumentDB. + * + *

Boots a bare {@link MongoTemplate} (no Spring Boot context) and exercises + * connect, index creation, insert, query, update, aggregation, unique-index + * enforcement, delete, and vector search — mirroring the sibling playgrounds. + * Prints a {@code Passed: N Failed: N} summary and exits non-zero on failure. + */ +public final class CrudCompatibilityTest { + + private static int passed = 0; + private static int failed = 0; + + /** Test-only POJO for the typed Spring Data operations. */ + public static class Widget { + @Id + public String id; + public String sku; + public String name; + public List tags; + public Double price; + public Boolean active; + public Instant createdAt; + } + + private interface Step { + void run() throws Exception; + } + + private static boolean isOk(Document result) { + Object ok = result.get("ok"); + return ok instanceof Number && ((Number) ok).doubleValue() == 1.0; + } + + private static void step(String name, Step action) { + try { + action.run(); + passed++; + System.out.println(" \u2705 " + name); + } catch (Exception | AssertionError ex) { + failed++; + System.out.println(" \u274C " + name + ": " + ex.getMessage()); + } + } + + public static void main(String[] args) { + String rawUri = args.length > 0 ? args[0] : System.getenv("MONGO_URI"); + boolean insecure = MongoClientFactory.insecureFromEnv(); + String dbName = System.getenv().getOrDefault("MONGO_DB", "springdata_test"); + + System.out.println("Spring Data MongoDB DocumentDB compatibility test"); + System.out.println("================================================="); + + MongoClient client = MongoClientFactory.create(rawUri, insecure); + MongoTemplate template = new MongoTemplate(client, dbName); + + long stamp = System.currentTimeMillis(); + String collection = "widgets_" + stamp; + String vectorCollection = "vectors_" + stamp; + + step("connect", () -> { + Document ping = template.executeCommand(new Document("ping", 1)); + if (!isOk(ping)) { + throw new AssertionError("ping did not return ok:1"); + } + }); + + step("create indexes", () -> { + template.indexOps(collection).ensureIndex( + new Index().on("sku", Sort.Direction.ASC).unique().named("sku_unique")); + template.indexOps(collection).ensureIndex( + new Index().on("name", Sort.Direction.ASC) + .on("price", Sort.Direction.DESC).named("name_price")); + }); + + step("insert (single)", () -> { + Widget widget = new Widget(); + widget.sku = "SKU-001"; + widget.name = "Gizmo"; + widget.tags = new ArrayList<>(List.of("alpha", "beta")); + widget.price = 9.99; + widget.active = true; + widget.createdAt = Instant.now(); + Widget saved = template.insert(widget, collection); + if (saved.id == null) { + throw new AssertionError("no _id assigned"); + } + }); + + step("insert (many)", () -> { + Widget gadget = new Widget(); + gadget.sku = "SKU-002"; + gadget.name = "Gadget"; + gadget.tags = new ArrayList<>(List.of("beta")); + gadget.price = 19.5; + + Widget widget = new Widget(); + widget.sku = "SKU-003"; + widget.name = "Widget"; + widget.tags = new ArrayList<>(List.of("alpha", "gamma")); + widget.price = 4.25; + + template.insert(List.of(gadget, widget), collection); + }); + + step("findById", () -> { + Widget first = template.findOne( + new Query(Criteria.where("sku").is("SKU-001")), Widget.class, collection); + if (first == null) { + throw new AssertionError("SKU-001 not found"); + } + Widget byId = template.findById(first.id, Widget.class, collection); + if (byId == null || !"SKU-001".equals(byId.sku)) { + throw new AssertionError("document not found or mismatched by _id"); + } + }); + + step("find with filter + sort + limit", () -> { + Query query = new Query(Criteria.where("price").gte(5)) + .with(Sort.by(Sort.Direction.DESC, "price")) + .limit(10); + List docs = template.find(query, Widget.class, collection); + if (docs.size() != 2) { + throw new AssertionError("expected 2 docs, got " + docs.size()); + } + if (docs.get(0).price < docs.get(1).price) { + throw new AssertionError("sort order incorrect"); + } + }); + + step("count", () -> { + long count = template.count(new Query(), Widget.class, collection); + if (count != 3) { + throw new AssertionError("expected 3 docs, got " + count); + } + }); + + step("updateFirst ($set)", () -> { + var result = template.updateFirst( + new Query(Criteria.where("sku").is("SKU-002")), + new Update().set("price", 21), Widget.class, collection); + if (result.getModifiedCount() != 1) { + throw new AssertionError("expected 1 modified, got " + result.getModifiedCount()); + } + }); + + step("findAndModify (returns new)", () -> { + Widget updated = template.findAndModify( + new Query(Criteria.where("sku").is("SKU-003")), + new Update().push("tags", "delta"), + FindAndModifyOptions.options().returnNew(true), + Widget.class, collection); + if (updated == null || !updated.tags.contains("delta")) { + throw new AssertionError("update not applied"); + } + }); + + step("aggregation ($unwind/$group/$sort)", () -> { + Aggregation aggregation = Aggregation.newAggregation( + Aggregation.unwind("tags"), + Aggregation.group("tags").count().as("count"), + Aggregation.sort(Sort.Direction.DESC, "count")); + AggregationResults stats = + template.aggregate(aggregation, collection, Document.class); + if (stats.getMappedResults().isEmpty()) { + throw new AssertionError("aggregation returned no results"); + } + }); + + step("unique index enforcement (duplicate sku rejected)", () -> { + Widget duplicate = new Widget(); + duplicate.sku = "SKU-001"; + duplicate.name = "Duplicate"; + try { + template.insert(duplicate, collection); + } catch (DuplicateKeyException expected) { + return; + } + throw new AssertionError("duplicate insert was not rejected"); + }); + + step("delete (single)", () -> { + var result = template.remove( + new Query(Criteria.where("sku").is("SKU-002")), Widget.class, collection); + if (result.getDeletedCount() != 1) { + throw new AssertionError("expected 1 deleted, got " + result.getDeletedCount()); + } + }); + + step("vector index + insert (cosmosSearch vector-ivf)", () -> { + MongoCollection vectors = template.getCollection(vectorCollection); + vectors.insertMany(List.of( + new Document("name", "a").append("vector", List.of(1.0, 0.0, 0.0)), + new Document("name", "b").append("vector", List.of(0.9, 0.1, 0.0)), + new Document("name", "c").append("vector", List.of(0.0, 0.0, 1.0)))); + Document command = new Document("createIndexes", vectorCollection) + .append("indexes", List.of(new Document("name", "vector_ivf") + .append("key", new Document("vector", "cosmosSearch")) + .append("cosmosSearchOptions", new Document("kind", "vector-ivf") + .append("numLists", 1) + .append("similarity", "COS") + .append("dimensions", 3)))); + Document result = template.executeCommand(command); + if (!isOk(result)) { + throw new AssertionError("createIndexes did not return ok:1"); + } + }); + + step("$vectorSearch returns nearest neighbor", () -> { + MongoCollection vectors = template.getCollection(vectorCollection); + Document search = new Document("$vectorSearch", new Document("index", "vector_ivf") + .append("path", "vector") + .append("queryVector", List.of(1.0, 0.0, 0.0)) + .append("numCandidates", 10) + .append("limit", 2)); + Document project = new Document("$project", new Document("name", 1).append("_id", 0)); + + List hits = new ArrayList<>(); + vectors.aggregate(List.of(search, project)).into(hits); + if (hits.isEmpty()) { + throw new AssertionError("vector search returned no results"); + } + if (!"a".equals(hits.get(0).getString("name"))) { + throw new AssertionError("expected nearest 'a', got '" + hits.get(0).getString("name") + "'"); + } + }); + + step("vector cleanup (drop collection)", () -> template.dropCollection(vectorCollection)); + step("cleanup (drop collection)", () -> template.dropCollection(collection)); + + client.close(); + + System.out.println("================================================="); + System.out.println("Passed: " + passed + " Failed: " + failed); + System.exit(failed == 0 ? 0 : 1); + } + + private CrudCompatibilityTest() { + } +} diff --git a/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/config/MongoConfig.java b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/config/MongoConfig.java new file mode 100644 index 0000000..ae81ee5 --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/config/MongoConfig.java @@ -0,0 +1,24 @@ +package com.example.playground.config; + +import com.example.playground.support.MongoClientFactory; +import com.mongodb.client.MongoClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Supplies the {@link MongoClient} bean. + * + *

Providing the client explicitly lets us translate the shared DocumentDB + * connection string into the options the MongoDB Java driver expects (see + * {@link MongoClientFactory}). Spring Boot's Mongo auto-configuration backs off + * when a {@code MongoClient} bean is present, and still uses + * {@code spring.data.mongodb.database} to pick the database. + */ +@Configuration +public class MongoConfig { + + @Bean + public MongoClient mongoClient() { + return MongoClientFactory.fromEnv(); + } +} diff --git a/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/model/Book.java b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/model/Book.java new file mode 100644 index 0000000..e5c0447 --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/model/Book.java @@ -0,0 +1,121 @@ +package com.example.playground.model; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.mapping.Document; + +/** + * Example "book" document, mirroring the Mongoose/Beanie/PyMongo playgrounds so + * the demos are directly comparable. + * + *

The compound index exercises DocumentDB index creation (via Spring Data + * {@code auto-index-creation}). No {@code collation} is declared; DocumentDB + * does not implement collation indexes. + */ +@Document(collection = "books") +@CompoundIndex(name = "author_title", def = "{'author': 1, 'title': 1}") +public class Book { + + @Id + private String id; + + private String title; + private String author; + private List genres = new ArrayList<>(); + private Integer pages; + private Instant published; + private Boolean inStock = Boolean.TRUE; + private Integer rating; + + @CreatedDate + private Instant createdAt; + + @LastModifiedDate + private Instant updatedAt; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public List getGenres() { + return genres; + } + + public void setGenres(List genres) { + this.genres = genres; + } + + public Integer getPages() { + return pages; + } + + public void setPages(Integer pages) { + this.pages = pages; + } + + public Instant getPublished() { + return published; + } + + public void setPublished(Instant published) { + this.published = published; + } + + public Boolean getInStock() { + return inStock; + } + + public void setInStock(Boolean inStock) { + this.inStock = inStock; + } + + public Integer getRating() { + return rating; + } + + public void setRating(Integer rating) { + this.rating = rating; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/repository/BookRepository.java b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/repository/BookRepository.java new file mode 100644 index 0000000..479ef0d --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/repository/BookRepository.java @@ -0,0 +1,13 @@ +package com.example.playground.repository; + +import java.util.List; + +import com.example.playground.model.Book; +import org.springframework.data.mongodb.repository.MongoRepository; + +/** Spring Data repository for {@link Book} documents. */ +public interface BookRepository extends MongoRepository { + + /** Derived query: exercises Spring Data query generation against DocumentDB. */ + List findByAuthorOrderByCreatedAtDesc(String author); +} diff --git a/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/support/MongoClientFactory.java b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/support/MongoClientFactory.java new file mode 100644 index 0000000..933ab2e --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/support/MongoClientFactory.java @@ -0,0 +1,83 @@ +package com.example.playground.support; + +import java.security.SecureRandom; +import java.security.cert.X509Certificate; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; + +/** + * Builds a {@link MongoClient} configured for the local DocumentDB emulator. + * + *

The emulator serves a self-signed certificate. The MongoDB Java driver + * cannot bypass certificate-chain validation via the connection string, so when + * running in insecure mode ({@code TLS_INSECURE != false}) this factory installs + * a trust-all {@link SSLContext} and allows invalid hostnames — the Java + * equivalent of the other drivers' {@code tlsAllowInvalidCertificates=true}. + * + *

For a real deployment, set {@code TLS_INSECURE=false} and configure a + * truststore containing the server's CA instead. + */ +public final class MongoClientFactory { + + private MongoClientFactory() { + } + + public static boolean insecureFromEnv() { + return !"false".equalsIgnoreCase( + System.getenv().getOrDefault("TLS_INSECURE", "true")); + } + + /** Builds a client from {@code MONGO_URI}/{@code TLS_INSECURE} in the environment. */ + public static MongoClient fromEnv() { + return create(System.getenv("MONGO_URI"), insecureFromEnv()); + } + + public static MongoClient create(String rawUri, boolean insecure) { + ConnectionString connectionString = new ConnectionString(MongoUriSupport.sanitize(rawUri)); + MongoClientSettings.Builder settings = MongoClientSettings.builder() + .applyConnectionString(connectionString); + + if (insecure) { + SSLContext sslContext = trustAllContext(); + settings.applyToSslSettings(ssl -> ssl + .enabled(true) + .invalidHostNameAllowed(true) + .context(sslContext)); + } + + return MongoClients.create(settings.build()); + } + + private static SSLContext trustAllContext() { + TrustManager[] trustAll = new TrustManager[] { + new X509TrustManager() { + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } + }; + try { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, trustAll, new SecureRandom()); + return context; + } catch (Exception ex) { + throw new IllegalStateException("Failed to build trust-all SSLContext", ex); + } + } +} diff --git a/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/support/MongoUriSupport.java b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/support/MongoUriSupport.java new file mode 100644 index 0000000..a3701a2 --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/support/MongoUriSupport.java @@ -0,0 +1,58 @@ +package com.example.playground.support; + +import java.util.ArrayList; +import java.util.List; + +/** + * Normalizes a DocumentDB connection string for the MongoDB Java driver. + * + *

The sibling playgrounds (Node.js / Python) connect with + * {@code tlsAllowInvalidCertificates=true}, but the Java driver does not + * recognize that option, and its {@code tlsInsecure} option only disables + * hostname verification — not certificate-chain validation. So this + * helper strips {@code tlsAllowInvalidCertificates} (the emulator's self-signed + * certificate is instead trusted via an SSLContext in {@link MongoClientFactory}) + * and removes {@code replicaSet}, which conflicts with {@code directConnection=true} + * against the standalone gateway. + */ +public final class MongoUriSupport { + + public static final String DEFAULT_URI = + "mongodb://docdbadmin:Documentdb!Local1@localhost:10260/" + + "?tls=true&tlsAllowInvalidCertificates=true&directConnection=true"; + + private MongoUriSupport() { + } + + /** Returns a Java-driver-compatible URI with unsupported options removed. */ + public static String sanitize(String uri) { + if (uri == null || uri.isBlank()) { + uri = DEFAULT_URI; + } + + int queryStart = uri.indexOf('?'); + if (queryStart < 0) { + return uri; + } + + String base = uri.substring(0, queryStart); + String[] params = uri.substring(queryStart + 1).split("&"); + List kept = new ArrayList<>(); + + for (String param : params) { + if (param.isEmpty()) { + continue; + } + int eq = param.indexOf('='); + String key = eq < 0 ? param : param.substring(0, eq); + + if (key.equalsIgnoreCase("replicaSet") + || key.equalsIgnoreCase("tlsAllowInvalidCertificates")) { + continue; + } + kept.add(param); + } + + return kept.isEmpty() ? base : base + "?" + String.join("&", kept); + } +} diff --git a/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/web/BookController.java b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/web/BookController.java new file mode 100644 index 0000000..a146dda --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/web/BookController.java @@ -0,0 +1,114 @@ +package com.example.playground.web; + +import static org.springframework.data.mongodb.core.aggregation.Aggregation.group; +import static org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation; +import static org.springframework.data.mongodb.core.aggregation.Aggregation.sort; +import static org.springframework.data.mongodb.core.aggregation.Aggregation.unwind; + +import java.util.List; +import java.util.Map; + +import com.example.playground.model.Book; +import com.example.playground.repository.BookRepository; +import org.bson.Document; +import org.springframework.data.domain.Sort.Direction; +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.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** REST API over the {@link Book} collection, mirroring the sibling playgrounds. */ +@RestController +public class BookController { + + private final BookRepository books; + private final MongoTemplate mongoTemplate; + + public BookController(BookRepository books, MongoTemplate mongoTemplate) { + this.books = books; + this.mongoTemplate = mongoTemplate; + } + + @PostMapping("/books") + public ResponseEntity create(@RequestBody Book book) { + if (isBlank(book.getTitle()) || isBlank(book.getAuthor())) { + return ResponseEntity.badRequest().body(Map.of("error", "title and author are required")); + } + book.setId(null); + if (book.getInStock() == null) { + book.setInStock(Boolean.TRUE); + } + return ResponseEntity.status(201).body(books.save(book)); + } + + @GetMapping("/books") + public ResponseEntity list(@RequestParam(required = false) String author) { + if (author != null) { + String trimmed = author.trim(); + if (trimmed.isEmpty() || trimmed.length() > 100) { + return ResponseEntity.badRequest() + .body(Map.of("error", "author must be a non-empty string up to 100 characters")); + } + List matches = books.findByAuthorOrderByCreatedAtDesc(trimmed); + return ResponseEntity.ok(Map.of("count", matches.size(), "books", matches)); + } + List all = books.findAll(); + return ResponseEntity.ok(Map.of("count", all.size(), "books", all)); + } + + @GetMapping("/books/{id}") + public ResponseEntity get(@PathVariable String id) { + return books.findById(id) + .>map(ResponseEntity::ok) + .orElseGet(() -> ResponseEntity.status(404).body(Map.of("error", "not found"))); + } + + @PatchMapping("/books/{id}") + public ResponseEntity update(@PathVariable String id, @RequestBody Book changes) { + return books.findById(id) + .>map(existing -> { + if (changes.getTitle() != null) existing.setTitle(changes.getTitle()); + if (changes.getAuthor() != null) existing.setAuthor(changes.getAuthor()); + if (changes.getGenres() != null) existing.setGenres(changes.getGenres()); + if (changes.getPages() != null) existing.setPages(changes.getPages()); + if (changes.getPublished() != null) existing.setPublished(changes.getPublished()); + if (changes.getInStock() != null) existing.setInStock(changes.getInStock()); + if (changes.getRating() != null) existing.setRating(changes.getRating()); + return ResponseEntity.ok(books.save(existing)); + }) + .orElseGet(() -> ResponseEntity.status(404).body(Map.of("error", "not found"))); + } + + @DeleteMapping("/books/{id}") + public ResponseEntity delete(@PathVariable String id) { + if (!books.existsById(id)) { + return ResponseEntity.status(404).body(Map.of("error", "not found")); + } + books.deleteById(id); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/stats/genres") + public ResponseEntity genreStats() { + Aggregation aggregation = newAggregation( + unwind("genres"), + group("genres").count().as("count"), + sort(Direction.DESC, "count")); + AggregationResults results = + mongoTemplate.aggregate(aggregation, "books", Document.class); + return ResponseEntity.ok(results.getMappedResults()); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/web/HealthController.java b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/web/HealthController.java new file mode 100644 index 0000000..bfa554b --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/java/com/example/playground/web/HealthController.java @@ -0,0 +1,34 @@ +package com.example.playground.web; + +import java.util.Map; + +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Liveness endpoint that pings DocumentDB through Spring Data. */ +@RestController +public class HealthController { + + private final MongoTemplate mongoTemplate; + + public HealthController(MongoTemplate mongoTemplate) { + this.mongoTemplate = mongoTemplate; + } + + @GetMapping("/health") + public ResponseEntity> health() { + try { + Document result = mongoTemplate.executeCommand(new Document("ping", 1)); + Object ok = result.get("ok"); + if (ok instanceof Number && ((Number) ok).doubleValue() == 1.0) { + return ResponseEntity.ok(Map.of("status", "healthy", "db", "connected")); + } + } catch (RuntimeException ex) { + return ResponseEntity.status(503).body(Map.of("status", "unhealthy", "db", ex.getMessage())); + } + return ResponseEntity.status(503).body(Map.of("status", "unhealthy", "db", "disconnected")); + } +} diff --git a/playgrounds/spring-data-mongodb/app/src/main/resources/application.properties b/playgrounds/spring-data-mongodb/app/src/main/resources/application.properties new file mode 100644 index 0000000..895cfe3 --- /dev/null +++ b/playgrounds/spring-data-mongodb/app/src/main/resources/application.properties @@ -0,0 +1,13 @@ +# Database Spring Data connects to. Overridden by the scripts (MONGO_DB env var). +spring.data.mongodb.database=${MONGO_DB:springdata_demo} + +# Let Spring Data create indexes from the @Document annotations at startup. +# DocumentDB builds them asynchronously; avoid `collation` (not implemented). +spring.data.mongodb.auto-index-creation=true + +# REST API port (overridden by the scripts via the PORT env var). +server.port=${PORT:3000} + +spring.main.banner-mode=off +logging.level.org.mongodb.driver=warn +logging.level.org.springframework.data.mongodb=warn diff --git a/playgrounds/spring-data-mongodb/scripts/lib.sh b/playgrounds/spring-data-mongodb/scripts/lib.sh new file mode 100755 index 0000000..1ae9baf --- /dev/null +++ b/playgrounds/spring-data-mongodb/scripts/lib.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +# Shared helpers for the Spring Data MongoDB local playground scripts. +# +# Everything runs on your machine: the DocumentDB local emulator runs in Docker +# and the app/test run as local JVM processes (via Maven) that connect to it +# directly. +set -euo pipefail + +# Connection defaults. Override any of these via environment variables. +DOCUMENTDB_CONTAINER="${DOCUMENTDB_CONTAINER:-documentdb-local}" +DOCUMENTDB_IMAGE="${DOCUMENTDB_IMAGE:-ghcr.io/documentdb/documentdb/documentdb-local:latest}" +DOCUMENTDB_HOST="${DOCUMENTDB_HOST:-localhost}" +DOCUMENTDB_PORT="${DOCUMENTDB_PORT:-10260}" +# Note: the emulator rejects some reserved names (e.g. "documentdb"); use a +# distinct admin username. +DOCUMENTDB_USERNAME="${DOCUMENTDB_USERNAME:-docdbadmin}" +DOCUMENTDB_PASSWORD="${DOCUMENTDB_PASSWORD:-Documentdb!Local1}" + +# Build the MongoDB connection string for the local emulator. The gateway only +# speaks TLS and advertises itself as a standalone server, so we request TLS, +# accept its self-signed cert, and use a direct connection. +# +# Note: this emits the same `tlsAllowInvalidCertificates=true` option the other +# playgrounds use. The MongoDB *Java* driver spells that option differently, so +# the application/test translate it to `tlsInsecure=true` at connect time (see +# MongoUriSupport.java). +build_uri() { + echo "mongodb://${DOCUMENTDB_USERNAME}:${DOCUMENTDB_PASSWORD}@${DOCUMENTDB_HOST}:${DOCUMENTDB_PORT}/?tls=true&tlsAllowInvalidCertificates=true&directConnection=true" +} + +require_docker() { + command -v docker >/dev/null || { + echo "docker is required (start Docker Desktop / the Docker daemon first)" >&2 + exit 1 + } + docker info >/dev/null 2>&1 || { + echo "Cannot reach the Docker daemon. Is Docker running?" >&2 + exit 1 + } +} + +# Resolve a Maven command: prefer the project wrapper (./mvnw) when present, +# otherwise fall back to a system `mvn`. Echoes the command to use. +# Args: +resolve_maven() { + local app_dir="$1" + if [ -x "$app_dir/mvnw" ]; then + echo "$app_dir/mvnw" + elif command -v mvn >/dev/null; then + echo "mvn" + else + echo "Maven is required: install 'mvn' or add the Maven wrapper (mvnw)." >&2 + exit 1 + fi +} + +require_java() { + command -v java >/dev/null || { + echo "java (JDK 17+) is required" >&2 + exit 1 + } +} + +container_running() { + [ "$(docker inspect -f '{{.State.Running}}' "$DOCUMENTDB_CONTAINER" 2>/dev/null)" = "true" ] +} + +container_exists() { + docker inspect "$DOCUMENTDB_CONTAINER" >/dev/null 2>&1 +} + +ensure_documentdb() { + require_docker + + if container_running; then + echo "DocumentDB container '$DOCUMENTDB_CONTAINER' is already running." + else + if container_exists; then + docker rm -f "$DOCUMENTDB_CONTAINER" >/dev/null 2>&1 || true + fi + + echo "Starting DocumentDB container '$DOCUMENTDB_CONTAINER' on port ${DOCUMENTDB_PORT} ..." + docker run -dt \ + -p "127.0.0.1:${DOCUMENTDB_PORT}:10260" \ + --name "$DOCUMENTDB_CONTAINER" \ + "$DOCUMENTDB_IMAGE" \ + --username "$DOCUMENTDB_USERNAME" \ + --password "$DOCUMENTDB_PASSWORD" >/dev/null + fi + + wait_for_documentdb +} + +wait_for_documentdb() { + local uri + uri="$(build_uri)" + echo "Waiting for DocumentDB to accept connections ..." + + local i + for i in $(seq 1 60); do + if docker exec "$DOCUMENTDB_CONTAINER" mongosh "$uri" \ + --quiet --eval 'db.adminCommand({ ping: 1 })' >/dev/null 2>&1; then + echo "DocumentDB is ready." + return 0 + fi + sleep 2 + done + + echo "DocumentDB did not become ready in time." >&2 + echo "Check logs with: docker logs $DOCUMENTDB_CONTAINER" >&2 + return 1 +} + +stop_documentdb() { + require_docker + if container_exists; then + echo "Removing DocumentDB container '$DOCUMENTDB_CONTAINER' ..." + docker rm -f "$DOCUMENTDB_CONTAINER" >/dev/null + echo "Done." + else + echo "No DocumentDB container named '$DOCUMENTDB_CONTAINER' found." + fi +} diff --git a/playgrounds/spring-data-mongodb/scripts/run-app.sh b/playgrounds/spring-data-mongodb/scripts/run-app.sh new file mode 100755 index 0000000..850e0ed --- /dev/null +++ b/playgrounds/spring-data-mongodb/scripts/run-app.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Run the Spring Data MongoDB demo API locally against a local DocumentDB +# container. +# +# Starts DocumentDB in Docker (if not already running), then builds and runs the +# Spring Boot app with Maven. +# +# Prerequisites: docker, java (JDK 17+), and Maven (system `mvn` or ./mvnw). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +APP_DIR="$SCRIPT_DIR/../app" +PORT="${PORT:-3000}" + +require_java +MVN="$(resolve_maven "$APP_DIR")" + +ensure_documentdb + +echo "" +echo "=== Spring Data MongoDB demo API running locally ===" +echo "API: http://localhost:${PORT}" +echo "Health: curl http://localhost:${PORT}/health" +echo "Create: curl -X POST http://localhost:${PORT}/books -H 'Content-Type: application/json' \\" +echo " -d '{\"title\":\"Dune\",\"author\":\"Herbert\",\"genres\":[\"sci-fi\"],\"pages\":412}'" +echo "Press Ctrl-C to stop (DocumentDB keeps running; stop it with ./scripts/stop-documentdb.sh)." +echo "" + +MONGO_URI="$(build_uri)" \ +MONGO_DB="${MONGO_DB:-springdata_demo}" \ +PORT="$PORT" \ + "$MVN" -q -f "$APP_DIR/pom.xml" spring-boot:run diff --git a/playgrounds/spring-data-mongodb/scripts/run-test.sh b/playgrounds/spring-data-mongodb/scripts/run-test.sh new file mode 100755 index 0000000..459506c --- /dev/null +++ b/playgrounds/spring-data-mongodb/scripts/run-test.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Run the Spring Data MongoDB CRUD/compatibility suite end-to-end against a local +# DocumentDB container. +# +# Starts DocumentDB in Docker (if not already running), then compiles and runs +# the standalone compatibility suite (CrudCompatibilityTest) with Maven. +# +# Prerequisites: docker, java (JDK 17+), and Maven (system `mvn` or ./mvnw). +# +# Set KEEP_DB=0 to remove the DocumentDB container when the tests finish +# (default keeps it running for fast re-runs). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +APP_DIR="$SCRIPT_DIR/../app" +KEEP_DB="${KEEP_DB:-1}" + +require_java +MVN="$(resolve_maven "$APP_DIR")" + +ensure_documentdb + +if [ "$KEEP_DB" != "1" ]; then + trap 'stop_documentdb' EXIT +fi + +echo "" +MONGO_URI="$(build_uri)" \ +MONGO_DB="${MONGO_DB:-springdata_test}" \ + "$MVN" -q -f "$APP_DIR/pom.xml" compile exec:java diff --git a/playgrounds/spring-data-mongodb/scripts/start-documentdb.sh b/playgrounds/spring-data-mongodb/scripts/start-documentdb.sh new file mode 100755 index 0000000..6bee42c --- /dev/null +++ b/playgrounds/spring-data-mongodb/scripts/start-documentdb.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Start a local DocumentDB instance in Docker and wait until it is ready. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +ensure_documentdb + +echo "" +echo "DocumentDB is running locally." +echo " Connection string: $(build_uri)" +echo " Stop it with: ./scripts/stop-documentdb.sh" diff --git a/playgrounds/spring-data-mongodb/scripts/stop-documentdb.sh b/playgrounds/spring-data-mongodb/scripts/stop-documentdb.sh new file mode 100755 index 0000000..58ed772 --- /dev/null +++ b/playgrounds/spring-data-mongodb/scripts/stop-documentdb.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Stop and remove the local DocumentDB container. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=lib.sh +source "$SCRIPT_DIR/lib.sh" + +stop_documentdb