diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..4420e32 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew dependencies:*)", + "WebSearch", + "WebFetch(domain:java.testcontainers.org)", + "WebFetch(domain:docs.openrewrite.org)", + "WebFetch(domain:github.com)", + "Bash(timeout 120 ./gradlew test:*)", + "Bash(gtimeout:*)", + "Bash(perl -e:*)", + "WebFetch(domain:mvnrepository.com)", + "Bash(javap:*)", + "Bash(TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock ./gradlew test:*)", + "Bash(docker stop:*)", + "Bash(./gradlew clean:*)" + ] + } +} diff --git a/ANALYSIS.md b/ANALYSIS.md new file mode 100644 index 0000000..12171b9 --- /dev/null +++ b/ANALYSIS.md @@ -0,0 +1,324 @@ +# Flamingock MongoDB Template - Comprehensive Module Analysis + +**Module:** `flamingock-java-template-mongodb` v1.0.0-rc.1 +**Flamingock Core:** v1.1.0-rc.2 +**Java Target:** 8 +**MongoDB Driver:** 4.0.0 (compileOnly) + +--- + +## 1. Architecture Overview + +The module implements a declarative MongoDB change template for the Flamingock migration framework. Users define MongoDB operations in YAML files with apply/rollback step pairs. The framework parses YAML into `MongoOperation` POJOs, validates them, resolves the appropriate operator, and executes against a `MongoDatabase`. + +**Execution flow:** +``` +YAML -> MongoOperation (deserialized) -> MongoOperationValidator -> MongoOperationType (enum factory) -> MongoOperator subclass -> MongoDB Driver +``` + +**Key classes (28 production source files):** +- `MongoChangeTemplate` - Entry point, `@ChangeTemplate(multiStep = true)` +- `MongoOperation` - YAML model (type, collection, parameters) +- `MongoOperationType` - Enum with 11 operations + factory +- `MongoOperationValidator` - Pre-execution validation +- 11 operator classes (`CreateCollectionOperator`, `InsertOperator`, etc.) +- 6 mapper classes (`IndexOptionsMapper`, `MapperUtil`, etc.) + +--- + +## 2. Top 10 Issues (Ranked by Severity) + +### #1 - CRITICAL: Rollback path skips validation entirely +**File:** `MongoChangeTemplate.java:101-105` +**Impact:** A malformed rollback YAML (wrong type, missing collection, injection via `$` in collection name) executes without any validation check. The apply path validates via `MongoOperationValidator.validate()` at line 93, but the rollback method directly calls `rollbackPayload.getOperator(db).apply(clientSession)` without any validation. +**Risk:** A rollback triggered during a production failure will amplify damage if the rollback YAML itself is malformed. The exact scenario where you need rollback to work perfectly is the scenario where it's least tested. +**Fix:** Call `MongoOperationValidator.validate(rollbackPayload, changeId)` in the `rollback()` method before executing. + +### #2 - HIGH: InsertOperator silently swallows null/empty documents, bypassing validation +**File:** `InsertOperator.java:37-39` +**Impact:** The validator correctly rejects empty/null documents (`MongoOperationValidator.java:268-271`), but `InsertOperator.applyInternal()` has a redundant guard: `if(op.getDocuments() == null || op.getDocuments().isEmpty()) { return; }`. If the validator is ever bypassed (e.g., rollback path per issue #1, or direct operator usage), the insert silently does nothing. A migration that is supposed to seed data will silently succeed without inserting anything - an invisible data loss scenario. +**Fix:** Remove the silent return. Let the operator trust the validator. If documents are null/empty at this point, it's a bug that should throw, not be silenced. + +### #3 - HIGH: `modifyCollection` has ZERO parameter validation +**File:** `MongoOperationValidator.java:239-240` +**Impact:** The `validateByType()` switch falls through to `default: return new ArrayList<>()` for `MODIFY_COLLECTION`, `DROP_COLLECTION`, `DROP_VIEW`, and `CREATE_COLLECTION`. While the last three genuinely need only a collection name, `modifyCollection` accepts `validator`, `validationLevel`, and `validationAction` parameters. None are validated. A user could pass `validationLevel: "banana"` and it would pass validation, only to fail at MongoDB runtime. +**Risk:** Silent misconfiguration of collection-level validation rules in production. +**Fix:** Add `case MODIFY_COLLECTION: return validateModifyCollection(op, entityId);` with checks for valid `validationLevel` values (`off`, `strict`, `moderate`) and valid `validationAction` values (`error`, `warn`). + +### #4 - HIGH: Type-unsafe parameter extraction throughout MongoOperation +**File:** `MongoOperation.java:46-115` +**Impact:** Every parameter getter uses `@SuppressWarnings("unchecked")` with raw casts from `Map`. Examples: +- `getDocuments()` (line 47): `(List>) parameters.get("documents")` - throws `ClassCastException` if YAML has `documents: "not a list"` +- `getKeys()` (line 54): `(Map) parameters.get("keys")` - same issue +- `getFilter()` (line 68): same pattern +- `getUpdate()` (line 108): same pattern + +While the validator catches some of these cases, the validator runs only on the apply path (see #1). Additionally, `getOptions()` (line 62) is called by operators but NEVER validated by `MongoOperationValidator` - an unknown key in `options` produces no error. +**Risk:** `ClassCastException` at runtime with unhelpful stack trace instead of a clear validation error. +**Fix:** Either add type checking in the getters (return Optional/throw descriptive error), or ensure validation is exhaustive for all parameter access paths. + +### #5 - HIGH: CreateIndexOperator claims `transactional=true` but ignores the session +**File:** `CreateIndexOperator.java:28,32-35` +**Impact:** Constructor passes `super(mongoDatabase, operation, true)`, declaring itself transactional. But `applyInternal()` at line 33-35 warns `"MongoDB does not support transactions for createCollection operation"` (note: wrong operation name in the message - says "createCollection" instead of "createIndex") and then proceeds to ignore the `clientSession` entirely. The base class `MongoOperator.logOperation()` at line 46-48 will say "Applying transactional operation with transaction" when a session is present, which is misleading since the session is not actually used. +**Risk:** Users think index creation is transactional when it is not. If a multi-step change fails after `createIndex`, the framework may expect the transaction to roll it back, but it was never part of the transaction. +**Fix:** Change to `super(mongoDatabase, operation, false)`. Fix the warning message to say "createIndex" instead of "createCollection". + +### #6 - MEDIUM: DropIndexOperator completely ignores ClientSession +**File:** `DropIndexOperator.java:29-35` +**Impact:** Unlike other operators that check `if (clientSession != null)` and use it, `DropIndexOperator` never references `clientSession` at all (it receives it as a parameter but discards it). It's marked `transactional=false` in the constructor (line 25), which is correct, but the logging from `MongoOperator.logOperation()` will still produce a confusing info message if a session is present: `"DropIndexOperator is not transactional, but Change has been marked as transactional. Transaction ignored."` This is acceptable behavior but worth documenting. +**Fix:** Minor - add a comment explaining this is intentional. The behavior is correct. + +### #7 - MEDIUM: `getCollation()` in MapperUtil will always fail for YAML-sourced input +**File:** `MapperUtil.java:87-94` +**Impact:** The method checks `if (value instanceof Collation)` and otherwise throws `IllegalArgumentException`. When YAML is parsed, collation will be deserialized as a `Map`, never as a `Collation` object. This means the `collation` option in `IndexOptionsMapper` (line 92), `UpdateOptionsMapper` (line 41), and `CreateViewOptionsMapper` (line 31) can never work from YAML input. It will always throw `"field[collation] should be Collation"`. +**Risk:** Documented options that are impossible to use. Users who try `collation` in YAML will get a confusing error. +**Fix:** Implement Map-to-Collation conversion similar to how `getBson()` handles `Map` -> `BsonDocument` conversion. The Collation builder needs to be populated from the map keys (`locale`, `strength`, `caseLevel`, etc.). + +### #8 - MEDIUM: DeleteOperator always uses `deleteMany`, no `deleteOne` support +**File:** `DeleteOperator.java:36-38` +**Impact:** Unlike `UpdateOperator` which supports `multi: true/false` to switch between `updateOne`/`updateMany`, `DeleteOperator` always calls `collection.deleteMany()`. There is no `multi` parameter or any way to delete a single document. The `delete` operation documentation in `MongoOperationValidator.java:80-88` shows `filter` is the only parameter, confirming `deleteOne` is not supported. +**Risk:** Users cannot safely delete a single document matching a filter when multiple documents could match. All matching documents are always deleted. +**Fix:** Add `multi` parameter support (defaulting to `true` for backwards compatibility, or `false` to match MongoDB's default `deleteOne`). Consider the migration safety implications of the default. + +### #9 - MEDIUM: Unknown YAML fields are silently accepted +**Impact:** The YAML deserialization into `MongoOperation` only maps `type`, `collection`, and `parameters`. Any extra top-level field (e.g., a typo like `colection` or `paramters`) is silently ignored. Within `parameters`, the validator checks for specific required keys but never rejects unknown keys. A user could write `parameters: { documets: [...] }` (typo) for an insert, and the validator would correctly fail with "requires 'documents' parameter", but a field like `parameters: { documents: [...], unknown_field: true }` passes silently. +**Risk:** Typos in parameter names that don't affect required-field validation go undetected. +**Fix:** Add strict mode option that warns or rejects unknown parameter keys per operation type. + +### #10 - LOW: Duplicate logger field in CreateCollectionOperator +**File:** `CreateCollectionOperator.java:25` +**Impact:** `CreateCollectionOperator` declares `protected static final Logger logger = FlamingockLoggerFactory.getLogger("CreateCollection")` which shadows the parent's `MongoOperator.logger` field (also `protected static final Logger logger` at line 25). Both are static, so the parent's `logOperation()` method uses `MongoOperator.logger` ("MongoTemplate") while `CreateCollectionOperator.applyInternal()` uses its own `logger` ("CreateCollection"). This inconsistency means log messages from the same operation go to different logger names. +**Fix:** Remove the duplicate logger declaration from `CreateCollectionOperator`. Let it inherit the parent's. + +--- + +## 3. Operation Coverage Matrix + +| Operation | Enum Value | Operator Class | Transactional | Validation | Options Mapper | Session Handling | Unit Test | Integration Test | +|-----------|-----------|---------------|:---:|:---:|:---:|:---:|:---:|:---:| +| createCollection | `CREATE_COLLECTION` | `CreateCollectionOperator` | No | collection only | None | Warns & ignores | `CreateCollectionOperatorTest` (1 test) | YAML `_0001` | +| dropCollection | `DROP_COLLECTION` | `DropCollectionOperator` | No | collection only | None | Ignores entirely | `DropCollectionOperatorTest` (1 test) | YAML `_0004` rollback | +| insert | `INSERT` | `InsertOperator` | Yes | Full (documents) | `InsertOptionsMapper` | Full | `InsertOperatorTest` (3 tests) | YAML `_0002`, `_0003`, `_0005` | +| update | `UPDATE` | `UpdateOperator` | Yes | Full (filter, update) | `UpdateOptionsMapper` | Full | `UpdateOperatorTest` (1 test) | None | +| delete | `DELETE` | `DeleteOperator` | Yes | filter required | None | Full | `DeleteOperatorTest` (1 test) | YAML `_0002` rollback | +| createIndex | `CREATE_INDEX` | `CreateIndexOperator` | **Yes (wrong)** | Full (keys) | `IndexOptionsMapper` | **Warns & ignores** | `CreateIndexOperatorTest` (1 test) | YAML `_0003`, `_0005` | +| dropIndex | `DROP_INDEX` | `DropIndexOperator` | No | indexName or keys | None | **Ignores entirely** | `DropIndexOperatorTest` (1 test) | YAML `_0005` rollback | +| renameCollection | `RENAME_COLLECTION` | `RenameCollectionOperator` | No | target required | `RenameCollectionOptionsMapper` | Ignores entirely | `RenameCollectionOperatorTest` (1 test) | None | +| modifyCollection | `MODIFY_COLLECTION` | `ModifyCollectionOperator` | No | **None (bug)** | None | Ignores entirely | `ModifyCollectionOperatorTest` (1 test) | None | +| createView | `CREATE_VIEW` | `CreateViewOperator` | No | Full (viewOn, pipeline) | `CreateViewOptionsMapper` | Ignores entirely | `CreateViewOperatorTest` (1 test) | None | +| dropView | `DROP_VIEW` | `DropViewOperator` | No | collection only | None | Ignores entirely | `DropViewOperatorTest` (1 test) | None | + +### Coverage Notes: +- **5 of 11 operations** have integration test coverage via YAML changes (createCollection, insert, delete, createIndex, dropIndex) +- **6 operations** are only tested at the unit level: update, renameCollection, modifyCollection, createView, dropView, dropCollection (though dropCollection appears in YAML rollback steps) +- **0 operations** are tested with `ClientSession` (transactional path) +- No tests exercise the `options` mappers in integration (e.g., insert with `bypassDocumentValidation`, index with `unique` option via YAML) + +--- + +## 4. Test Coverage Gap Analysis + +### 4.1 Current Test Inventory + +| Test Class | Test Count | Type | Mongo Required | +|-----------|:---------:|:----:|:-:| +| `MongoChangeTemplateTest` | 5 | Integration (full Flamingock pipeline) | Yes | +| `MongoOperationValidatorTest` | 38 (in nested classes) | Unit (pure logic) | No | +| `InsertOperatorTest` | 3 | Integration (operator-level) | Yes | +| `MultipleOperationsTest` | 3 | Integration (operator-level) | Yes | +| `CreateCollectionOperatorTest` | 1 | Integration | Yes | +| `DropCollectionOperatorTest` | 1 | Integration | Yes | +| `CreateIndexOperatorTest` | 1 | Integration | Yes | +| `DropIndexOperatorTest` | 1 | Integration | Yes | +| `CreateViewOperatorTest` | 1 | Integration | Yes | +| `DropViewOperatorTest` | 1 | Integration | Yes | +| `DeleteOperatorTest` | 1 | Integration | Yes | +| `UpdateOperatorTest` | 1 | Integration | Yes | +| `RenameCollectionOperatorTest` | 1 | Integration | Yes | +| `ModifyCollectionOperatorTest` | 1 | Integration | Yes | +| `IndexOptionsMapperTest` | ~15 | Unit | No | +| `MapperUtilTest` | ~10 | Unit | No | +| `InsertOptionsMapperTest` | ~3 | Unit | No | +| `UpdateOptionsMapperTest` | ~3 | Unit | No | +| `CreateViewOptionsMapperTest` | ~2 | Unit | No | +| `RenameCollectionOptionsMapperTest` | ~2 | Unit | No | + +### 4.2 Critical Missing Tests + +**P0 - Must have before GA:** + +1. **Transactional path tests** - Zero tests exercise any operator with a `ClientSession`. The entire transactional execution path (`seed-users` YAML is `transactional: true`) is only tested in the happy-path integration test where all changes succeed. No test verifies: + - Transaction commit after successful apply + - Transaction rollback after failed apply + - Behavior when `isTransactional=true` but `clientSession=null` (tested implicitly by `validateSession` but no explicit test) + +2. **Rollback validation test** - No test verifies that a malformed rollback payload fails gracefully (currently it doesn't fail at all - see issue #1) + +3. **Validation-operator alignment test** - No test verifies that every code path in operators is covered by the validator. For example, `InsertOperator.getDocuments()` can throw `ClassCastException` if documents is wrong type - is the validator always called first? + +4. **Error message tests for CreateIndexOperator** - The warning message says "createCollection" when it should say "createIndex" (issue #5) + +**P1 - Important:** + +5. **Options integration tests** - No operator test verifies behavior with non-default options: + - `insert` with `bypassDocumentValidation: true` + - `insert` with `ordered: false` + - `createIndex` with `unique: true`, `sparse: true`, `expireAfterSeconds` + - `update` with `upsert: true` + - `update` with `arrayFilters` + - `renameCollection` with `dropTarget: true` + +6. **`modifyCollection` tests** - Only 1 test (happy path with validator). Need tests for: + - modifyCollection with invalid `validationLevel` + - modifyCollection with no parameters at all (currently accepted by validator) + - modifyCollection with `validationAction` + +7. **Edge case tests for MongoOperation getters:** + - `getDocuments()` when `parameters` is null (NPE) + - `getKeys()` when keys value is not a Map (ClassCastException) + - `getFilter()` when filter value is not a Map + - `isMulti()` when multi value is a String "true" instead of boolean + +8. **`deleteOne` vs `deleteMany` behavior test** - Verify that delete always uses `deleteMany` (documenting current behavior) + +9. **Idempotency tests:** + - `createCollection` when collection already exists (throws `MongoCommandException`) + - `dropCollection` when collection doesn't exist (should be no-op) + - `createIndex` when index already exists + - `dropIndex` when index doesn't exist + +10. **Collation test** - Verify that `getCollation()` fails for YAML input (documenting issue #7) + +--- + +## 5. Robustness Checklist + +| Criterion | Status | Details | +|-----------|:------:|---------| +| Null input handling | PARTIAL | Validator handles null operation, null type, null collection. But `MongoOperation` getters don't handle null parameters (NPE in `getDocuments()` etc.) | +| Type safety | WEAK | Extensive `@SuppressWarnings("unchecked")` throughout `MongoOperation`. Raw casts from `Map` with no type checking at getter level | +| Error collection (vs fail-fast) | GOOD | `MongoOperationValidator` collects all errors before throwing. Clean `ValidationError` model with entityId/entityType/message | +| Exception hierarchy | GOOD | `MongoTemplateValidationException` extends `RuntimeException`, carries structured `List`, formatted message | +| Logging | GOOD | `MongoOperator` base class logs transactional/non-transactional status for every operation. Clear warning when transactional mismatch | +| Immutability | WEAK | `MongoOperation` is fully mutable (public setters). No defensive copying of `parameters` map. `ValidationError` is properly immutable | +| Thread safety | N/A | Template instances are per-execution, not shared. Static logger is safe | +| Idempotency | NOT HANDLED | No idempotency checks. `createCollection` will fail if collection exists. Operators trust that the framework manages idempotency via audit store | +| Backwards compatibility | GOOD | `compileOnly` MongoDB driver 4.0.0 is a low bar. Index options correctly throw `UnsupportedOperationException` for removed options (`bucketSize`, `wildcardProjection`, `hidden`) | +| Resource cleanup | N/A | No resources to clean up. Operators use driver-level collections which are managed by the MongoDB client | + +--- + +## 6. Security and Safety Assessment + +### 6.1 Collection Name Injection +**Status: PARTIALLY MITIGATED** +The validator checks for `$` and `\0` in collection names (`MongoOperationValidator.java:210-216`), which prevents MongoDB operator injection (e.g., `$cmd`) and null-byte attacks. However: +- Collection name length is not validated (MongoDB limit: 120 bytes in namespace) +- `system.` prefix is not blocked (e.g., `system.users` could be targeted) +- The `target` parameter in `renameCollection` is validated for empty/null but NOT for `$` or `\0` characters + +### 6.2 Arbitrary Command Execution +**Status: LOW RISK** +`ModifyCollectionOperator` uses `mongoDatabase.runCommand()` (line 41), which is the most powerful MongoDB operation. However, the command is built programmatically with `collMod` prefix (line 31), so it cannot be repurposed for arbitrary commands. The `validator`, `validationLevel`, and `validationAction` values are passed through without sanitization, but these are constrained by the `collMod` command schema. + +### 6.3 Data Destruction Safety +**Status: ACCEPTABLE WITH CAVEATS** +- `delete` with `filter: {}` deletes ALL documents (by design, documented) +- `dropCollection` is irreversible +- No confirmation or dry-run mode exists +- Rollback provides the safety net, but rollback itself is not validated (issue #1) + +### 6.4 YAML Deserialization +**Status: DELEGATED** +The module does not handle YAML parsing - it receives already-deserialized `MongoOperation` POJOs from the Flamingock framework. YAML deserialization safety is the framework's responsibility. + +--- + +## 7. PR-Ready Concrete Changes + +### Change 1: Add validation to rollback path +**File:** `MongoChangeTemplate.java` +**Lines:** 101-105 +**Change:** Add `MongoOperationValidator.validate(rollbackPayload, changeId)` check before executing rollback, matching the apply path. + +### Change 2: Remove silent return in InsertOperator +**File:** `InsertOperator.java` +**Lines:** 37-39 +**Change:** Remove the `if(op.getDocuments() == null || op.getDocuments().isEmpty()) { return; }` guard. The validator should be the single source of truth for input validation. + +### Change 3: Fix CreateIndexOperator transactional flag and error message +**File:** `CreateIndexOperator.java` +**Lines:** 28, 34 +**Change:** Change constructor to `super(mongoDatabase, operation, false)`. Fix warning message from "createCollection" to "createIndex". + +### Change 4: Add modifyCollection validation +**File:** `MongoOperationValidator.java` +**Line:** 239 +**Change:** Add `case MODIFY_COLLECTION: return validateModifyCollection(op, entityId);` with validation for `validationLevel` and `validationAction` enum values. + +### Change 5: Validate `target` in renameCollection for special characters +**File:** `MongoOperationValidator.java` +**Lines:** 374-391 +**Change:** Apply the same `$` and `\0` validation to the `target` parameter that is applied to `collection` names. + +### Change 6: Fix Collation mapping for YAML input +**File:** `MapperUtil.java` +**Lines:** 87-94 +**Change:** Add `else if (value instanceof Map)` branch to `getCollation()` that builds a `Collation` from the map using `Collation.builder()`. + +### Change 7: Remove duplicate logger in CreateCollectionOperator +**File:** `CreateCollectionOperator.java` +**Line:** 25 +**Change:** Delete the `protected static final Logger logger = ...` line. Inherit from `MongoOperator`. + +--- + +## 8. Code Quality Observations + +### Positive +- Clean separation of concerns: template / model / validation / operators / mappers +- Enum-based factory pattern in `MongoOperationType` is elegant and extensible +- Validator collects all errors (doesn't fail-fast) - good UX for YAML authors +- Template method pattern in `MongoOperator` with `apply()` / `applyInternal()` is well-designed +- Good use of `@NonLockGuarded` on `MongoOperation` model +- Comprehensive Javadoc on `MongoChangeTemplate` and `MongoOperationValidator` +- Test YAML files serve as excellent documentation of the YAML schema + +### Negative +- Heavy code duplication in `InsertOperator` and `UpdateOperator` (4 branches for session x options combinations) +- `MongoOperation` is a god object - it has getters for every operation type's parameters, even though each getter is only relevant to 1-2 operation types +- No builder pattern or factory for `MongoOperation` in tests - all tests manually construct via setters +- `MapperUtil` mixes concerns: type extraction + BSON conversion + Collation (broken) in one class +- Test infrastructure duplication - every integration test class independently sets up MongoDBContainer with identical boilerplate + +--- + +## 9. Final Score + +| Category | Weight | Score (1-10) | Weighted | +|----------|:------:|:----:|:------:| +| Architecture & Design | 20% | 8 | 1.60 | +| Implementation Correctness | 25% | 5 | 1.25 | +| Validation & Error Handling | 20% | 6 | 1.20 | +| Test Coverage | 20% | 4 | 0.80 | +| Security & Safety | 10% | 6 | 0.60 | +| Code Quality & Maintainability | 5% | 7 | 0.35 | +| **Total** | **100%** | | **5.8 / 10** | + +### Score Justification + +**Architecture (8/10):** Solid layered design. Template method pattern, enum factory, separated validation. Well-thought-out extension points. The multi-step template approach integrates cleanly with Flamingock framework. + +**Implementation Correctness (5/10):** The rollback validation gap (#1) is a critical defect. The CreateIndexOperator transactional mismatch (#5) is misleading. The broken Collation mapper (#7) means documented options don't work. InsertOperator's silent swallow (#2) undermines the validation layer. + +**Validation (6/10):** Good coverage for 7 of 11 operations. The validator's error-collection pattern is excellent. But `modifyCollection` having zero validation (#3), and the rollback path bypassing validation entirely (#1), significantly reduce confidence. + +**Test Coverage (4/10):** The validator tests are thorough (38 tests). But operator tests are overwhelmingly single happy-path tests (1 test each for 10 of 11 operators). Zero transactional path tests. Zero options-with-operator tests. No edge case or error path tests for operators. + +**Security (6/10):** Basic collection name sanitization is present. No arbitrary command execution risk. But `target` in rename is not sanitized, and `system.` prefix is not blocked. Acceptable for the current scope but needs tightening before GA. + +**Code Quality (7/10):** Clean code style, good naming, proper license headers. Javadoc where it matters. Deductions for code duplication in operators and the MongoOperation god-object pattern. + +### Bottom Line + +The module has a **solid architectural foundation** but is **not production-ready**. The critical rollback validation gap must be fixed before any release. The test suite needs significant expansion, particularly around transactional execution, error paths, and options handling. The 7 PR-ready changes identified above would raise the score to approximately **7.5/10**. diff --git a/README.md b/README.md index 1819c83..d5e0bde 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,8 @@ A Flamingock template for declarative MongoDB database operations using YAML-bas - **Declarative YAML-based changes** — Define MongoDB operations in simple YAML files - **11 supported operation types** — Collections, indexes, documents, and views -- **Rollback support** — Optional rollback operations for reversible changes +- **Step-based format** — Each change is a list of steps, each with an apply and optional rollback operation +- **Automatic rollback on failure** — The framework rolls back completed steps in reverse order if a step fails - **Transaction support** — Configurable transactional execution - **Java SPI integration** — Automatically discovered by Flamingock at runtime @@ -64,6 +65,8 @@ implementation("io.flamingock:flamingock-mongodb-sync-template:1.0.0") Place your change files in `src/main/resources/flamingock/changes/` (or your configured changes directory). +All changes use the `steps` format — a list of operations, each with an `apply` and optional `rollback`. Even single operations are wrapped in a step. + **Example: `_0001__create_users_collection.yaml`** ```yaml @@ -72,9 +75,13 @@ transactional: false template: MongoChangeTemplate targetSystem: id: "mongodb" -apply: - - type: createCollection - collection: users +steps: + - apply: + type: createCollection + collection: users + rollback: + type: dropCollection + collection: users ``` ### 2. Insert seed data @@ -87,44 +94,59 @@ transactional: true template: MongoChangeTemplate targetSystem: id: "mongodb" -apply: - - type: insert - collection: users - parameters: - documents: - - name: "Admin" - email: "admin@company.com" - roles: ["superuser"] - - name: "Backup" - email: "backup@company.com" - roles: ["readonly"] +steps: + - apply: + type: insert + collection: users + parameters: + documents: + - name: "Admin" + email: "admin@company.com" + roles: ["superuser"] + - name: "Backup" + email: "backup@company.com" + roles: ["readonly"] + rollback: + type: delete + collection: users + parameters: + filter: {} ``` -### 3. Create indexes with rollback +### 3. Multiple operations with rollback + +Group multiple operations in a single change. If a step fails, the framework automatically rolls back completed steps in reverse order. -**Example: `_0003__create_indexes.yaml`** +**Example: `_0003__setup_products.yaml`** ```yaml -id: create-user-indexes +id: setup-products transactional: false template: MongoChangeTemplate targetSystem: id: "mongodb" -apply: - - type: createIndex - collection: users - parameters: - keys: - email: 1 - options: - name: "email_unique_index" - unique: true - -rollback: - - type: dropIndex - collection: users - parameters: - indexName: "email_unique_index" + +steps: + - apply: + type: createCollection + collection: products + rollback: + type: dropCollection + collection: products + + - apply: + type: createIndex + collection: products + parameters: + keys: + category: 1 + options: + name: "category_index" + rollback: + type: dropIndex + collection: products + parameters: + indexName: "category_index" ``` --- @@ -149,6 +171,8 @@ rollback: ## 📄 YAML Structure +The MongoDB template uses the **steps format** — each change is a list of steps, where each step has an `apply` operation and an optional `rollback` operation. + ```yaml # Required: Unique identifier for this change id: my-change-id @@ -166,110 +190,188 @@ template: MongoChangeTemplate targetSystem: id: "mongodb" -# Required: List of operations to apply -apply: - - type: - collection: - parameters: - # Operation-specific parameters - -# Optional: List of operations to rollback -rollback: - - type: - collection: - parameters: - # Operation-specific parameters +# Required: List of steps, each with an apply and optional rollback +steps: + - apply: + type: + collection: + parameters: + # Operation-specific parameters + rollback: + type: + collection: + parameters: + # Operation-specific parameters + + - apply: + type: + collection: + rollback: + type: + collection: ``` +Steps are executed in order. If a step fails, the framework automatically rolls back all previously completed steps in reverse order (for steps that have a `rollback` defined). + --- ## 💡 Operation Examples +Each example below shows a step entry inside the `steps` list. + ### Create Collection ```yaml -apply: - - type: createCollection - collection: products +steps: + - apply: + type: createCollection + collection: products + rollback: + type: dropCollection + collection: products ``` ### Create Index ```yaml -apply: - - type: createIndex - collection: products - parameters: - keys: - category: 1 - price: -1 - options: - name: "category_price_index" - background: true +steps: + - apply: + type: createIndex + collection: products + parameters: + keys: + category: 1 + price: -1 + options: + name: "category_price_index" + background: true + rollback: + type: dropIndex + collection: products + parameters: + indexName: "category_price_index" ``` ### Insert Documents ```yaml -apply: - - type: insert - collection: products - parameters: - documents: - - name: "Widget" - price: 29.99 - category: "electronics" - - name: "Gadget" - price: 49.99 - category: "electronics" +steps: + - apply: + type: insert + collection: products + parameters: + documents: + - name: "Widget" + price: 29.99 + category: "electronics" + - name: "Gadget" + price: 49.99 + category: "electronics" + rollback: + type: delete + collection: products + parameters: + filter: {} ``` ### Update Documents ```yaml -apply: - - type: update - collection: products - parameters: - filter: - category: "electronics" - update: - $set: - discounted: true +steps: + - apply: + type: update + collection: products + parameters: + filter: + category: "electronics" + update: + $set: + discounted: true ``` ### Delete Documents ```yaml -apply: - - type: delete - collection: products - parameters: - filter: - discounted: true +steps: + - apply: + type: delete + collection: products + parameters: + filter: + discounted: true ``` ### Rename Collection ```yaml -apply: - - type: renameCollection - collection: oldName - parameters: - newName: newName +steps: + - apply: + type: renameCollection + collection: oldName + parameters: + newName: newName ``` ### Create View ```yaml -apply: - - type: createView - collection: activeUsers - parameters: - viewOn: users - pipeline: - - $match: - active: true +steps: + - apply: + type: createView + collection: activeUsers + parameters: + viewOn: users + pipeline: + - $match: + active: true +``` + +### Complete Example — Multiple Steps + +A full change file with multiple steps and paired rollbacks: + +```yaml +id: setup-orders +transactional: false +template: MongoChangeTemplate +targetSystem: + id: "mongodb" + +steps: + - apply: + type: createCollection + collection: orders + rollback: + type: dropCollection + collection: orders + + - apply: + type: insert + collection: orders + parameters: + documents: + - orderId: "ORD-001" + status: "pending" + rollback: + type: delete + collection: orders + parameters: + filter: {} + + - apply: + type: createIndex + collection: orders + parameters: + keys: + orderId: 1 + options: + name: "orderId_index" + unique: true + rollback: + type: dropIndex + collection: orders + parameters: + indexName: "orderId_index" ``` --- diff --git a/build.gradle.kts b/build.gradle.kts index 598d1b3..e9ad94b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,38 +1,14 @@ -import java.net.URL -import javax.xml.parsers.DocumentBuilderFactory - plugins { `java-library` `maven-publish` id("com.diffplug.spotless") version "6.25.0" } -fun flamingockVersion(): String { - var passedAsParameter = false - val flamingockVersionAsParameter: String? = project.findProperty("flamingockVersion")?.toString() - val flamingockVersion: String = if (flamingockVersionAsParameter != null) { - passedAsParameter = true - flamingockVersionAsParameter - } else { - val metadataUrl = "https://repo.maven.apache.org/maven2/io/flamingock/flamingock-core/maven-metadata.xml" - try { - val metadata = URL(metadataUrl).readText() - val documentBuilder = DocumentBuilderFactory.newInstance().newDocumentBuilder() - val inputStream = metadata.byteInputStream() - val document = documentBuilder.parse(inputStream) - document.getElementsByTagName("latest").item(0).textContent - } catch (e: Exception) { - throw RuntimeException("Cannot obtain Flamingock's latest version", e) - } - } - logger.lifecycle("Building with flamingock version${if (passedAsParameter) "[from parameter]" else ""}: $flamingockVersion") - return flamingockVersion -} -val flamingockVersion = flamingockVersion() +val flamingockVersion = "1.1.0-rc.2" group = "io.flamingock" -version = flamingockVersion +version = "1.0.0-rc.1" repositories { mavenLocal() diff --git a/settings.gradle.kts b/settings.gradle.kts index e55f996..6b78143 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1 +1 @@ -rootProject.name = "flamingock-mongodb-sync-template" +rootProject.name = "flamingock-java-template-mongodb" diff --git a/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java index 8b17922..97b6229 100644 --- a/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java +++ b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java @@ -18,6 +18,7 @@ import com.mongodb.client.ClientSession; import com.mongodb.client.MongoDatabase; import io.flamingock.api.annotations.Apply; +import io.flamingock.api.annotations.ChangeTemplate; import io.flamingock.api.annotations.Nullable; import io.flamingock.api.annotations.Rollback; import io.flamingock.api.template.AbstractChangeTemplate; @@ -25,154 +26,82 @@ import io.flamingock.template.mongodb.validation.MongoOperationValidator; import io.flamingock.template.mongodb.validation.MongoTemplateValidationException; import io.flamingock.template.mongodb.validation.ValidationError; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.Collection; import java.util.List; -import java.util.Map; /** * MongoDB Change Template for executing declarative MongoDB operations defined in YAML. * + *

This template extends {@link AbstractChangeTemplate} and is annotated with + * {@code @ChangeTemplate(multiStep = true)} for step-based changes where each step + * has its own apply and rollback operation. The framework manages step iteration, + * calling {@code @Apply} and {@code @Rollback} once per step with the appropriate + * payload set via {@code applyPayload} and {@code rollbackPayload}. + * *

YAML Structure

+ * *
{@code
  * id: create-orders-collection
- * transactional: true
+ * transactional: false
  * template: MongoChangeTemplate
  * targetSystem:
  *   id: "mongodb"
- * apply:
- *   - type: createCollection
- *     collection: orders
- *   - type: insert
- *     collection: orders
- *     parameters:
- *       documents:
- *         - orderId: "ORD-001"
- *           customer: "John Doe"
- * rollback:
- *   - type: delete
- *     collection: orders
- *     parameters:
- *       filter: {}
- *   - type: dropCollection
- *     collection: orders
+ * steps:
+ *   - apply:
+ *       type: createCollection
+ *       collection: orders
+ *     rollback:
+ *       type: dropCollection
+ *       collection: orders
+ *   - apply:
+ *       type: insert
+ *       collection: orders
+ *       parameters:
+ *         documents:
+ *           - orderId: "ORD-001"
+ *     rollback:
+ *       type: delete
+ *       collection: orders
+ *       parameters:
+ *         filter: {}
  * }
* *

Execution Behavior

*
    - *
  • Apply operations execute sequentially in order
  • - *
  • Rollback operations execute sequentially in the order defined by the user
  • - *
  • The framework handles rollback invocation on failure
  • + *
  • The framework iterates through steps, calling apply/rollback per step
  • + *
  • On failure, the framework rolls back completed steps in reverse order
  • *
  • In transactional mode, MongoDB transaction provides atomicity
  • *
* * @see MongoOperation */ -/* - * Backward Compatibility for YAML Formats - * - * This template supports two YAML structures: - * - New list format: apply: [- type: createCollection, - type: insert] - * - Old single format: apply: {type: createCollection, collection: users} - * - * The framework uses getApplyPayloadClass() to deserialize YAML payloads. - * Due to Java type erasure, List becomes List.class, which - * cannot deserialize the old Map format correctly. - * - * Solution: Override getApplyPayloadClass() to return Object.class, allowing - * the framework to pass raw YAML data (Map or List). The convertRawPayload() - * method then handles conversion to List for both formats. - * - * The converted operations are cached to avoid repeated conversion. - */ -public class MongoChangeTemplate extends AbstractChangeTemplate, List> { +@ChangeTemplate(multiStep = true) +public class MongoChangeTemplate extends AbstractChangeTemplate { - private List convertedApplyOps; - private List convertedRollbackOps; + private static final Logger log = LoggerFactory.getLogger(MongoChangeTemplate.class); public MongoChangeTemplate() { super(MongoOperation.class); } - /** - * Returns Object.class to allow the framework to pass raw YAML data without - * attempting to deserialize it as List. This enables backward compatibility - * with the old single-operation YAML format. - */ - @Override - @SuppressWarnings("unchecked") - public Class> getApplyPayloadClass() { - return (Class>) (Class) Object.class; - } - - /** - * Returns Object.class to allow the framework to pass raw YAML data without - * attempting to deserialize it as List. This enables backward compatibility - * with the old single-operation YAML format. - */ - @Override - @SuppressWarnings("unchecked") - public Class> getRollbackPayloadClass() { - return (Class>) (Class) Object.class; - } - @Apply public void apply(MongoDatabase db, @Nullable ClientSession clientSession) { validateSession(clientSession); - List operations = getConvertedApplyOperations(); - validatePayload(operations, changeId + ".apply"); - executeOperations(db, operations, clientSession); - } - - @Rollback - public void rollback(MongoDatabase db, @Nullable ClientSession clientSession) { - validateSession(clientSession); - List operations = getConvertedRollbackOperations(); - validatePayload(operations, changeId + ".rollback"); - executeOperations(db, operations, clientSession); - } - private List getConvertedApplyOperations() { - if (convertedApplyOps == null) { - convertedApplyOps = convertRawPayload(getRawPayload("applyPayload")); + List errors = MongoOperationValidator.validate(applyPayload, changeId); + if (!errors.isEmpty()) { + throw new MongoTemplateValidationException(errors); } - return convertedApplyOps; - } - private List getConvertedRollbackOperations() { - if (convertedRollbackOps == null) { - convertedRollbackOps = convertRawPayload(getRawPayload("rollbackPayload")); - } - return convertedRollbackOps; + applyPayload.getOperator(db).apply(clientSession); } - /** - * Accesses a payload field via reflection to avoid Java's checkcast instruction. - * Since we override getApplyPayloadClass() to return Object.class, the actual - * runtime value may be a Map (old format) or List (new format), not List. - */ - private Object getRawPayload(String fieldName) { - try { - java.lang.reflect.Field field = findField(getClass(), fieldName); - if (field != null) { - field.setAccessible(true); - return field.get(this); - } - } catch (Exception ignored) { - } - return null; - } - - private java.lang.reflect.Field findField(Class clazz, String fieldName) { - while (clazz != null) { - try { - return clazz.getDeclaredField(fieldName); - } catch (NoSuchFieldException e) { - clazz = clazz.getSuperclass(); - } - } - return null; + @Rollback + public void rollback(MongoDatabase db, @Nullable ClientSession clientSession) { + validateSession(clientSession); + rollbackPayload.getOperator(db).apply(clientSession); } private void validateSession(ClientSession clientSession) { @@ -181,99 +110,4 @@ private void validateSession(ClientSession clientSession) { String.format("Transactional change[%s] requires transactional ecosystem with ClientSession", changeId)); } } - - private void validatePayload(List operations, String entityId) { - if (operations == null || operations.isEmpty()) { - return; - } - - List errors = new ArrayList<>(); - for (int i = 0; i < operations.size(); i++) { - String opId = entityId + "[" + i + "]"; - errors.addAll(MongoOperationValidator.validate(operations.get(i), opId)); - } - - if (!errors.isEmpty()) { - throw new MongoTemplateValidationException(errors); - } - } - - private void executeOperations(MongoDatabase db, List operations, ClientSession clientSession) { - if (operations == null || operations.isEmpty()) { - return; - } - - for (MongoOperation op : operations) { - op.getOperator(db).apply(clientSession); - } - } - - @SuppressWarnings("unchecked") - private List convertRawPayload(Object rawPayload) { - if (rawPayload == null) { - return null; - } - - // Handle single MongoOperation (already converted) - if (rawPayload instanceof MongoOperation) { - List operations = new ArrayList<>(); - operations.add((MongoOperation) rawPayload); - return operations; - } - - // Handle single Map (backward compatibility) - if (rawPayload instanceof Map && !(rawPayload instanceof Collection)) { - Map map = (Map) rawPayload; - // Check if it looks like an operation (has 'type' field) - if (map.containsKey("type")) { - List operations = new ArrayList<>(); - operations.add(convertMapToOperation(map)); - return operations; - } - } - - // Handle Collection types - if (rawPayload instanceof Collection) { - Collection rawCollection = (Collection) rawPayload; - if (rawCollection.isEmpty()) { - return new ArrayList<>(); - } - - // Check if already properly deserialized - Object firstElement = rawCollection.iterator().next(); - if (firstElement instanceof MongoOperation) { - // If it's already a List, return as-is - if (rawPayload instanceof List) { - return (List) rawPayload; - } - // Otherwise convert to List - return new ArrayList<>((Collection) rawCollection); - } - - // Convert from LinkedHashMap elements - List operations = new ArrayList<>(); - for (Object item : rawCollection) { - if (item instanceof Map) { - operations.add(convertMapToOperation((Map) item)); - } - } - return operations; - } - - return new ArrayList<>(); - } - - @SuppressWarnings("unchecked") - private MongoOperation convertMapToOperation(Map map) { - MongoOperation op = new MongoOperation(); - op.setType((String) map.get("type")); - op.setCollection((String) map.get("collection")); - - Object params = map.get("parameters"); - if (params instanceof Map) { - op.setParameters((Map) params); - } - - return op; - } } diff --git a/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java b/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java index 05e72d1..81599d5 100644 --- a/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java +++ b/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java @@ -417,4 +417,5 @@ private static List validateCreateView(MongoOperation op, Strin return errors; } + } diff --git a/src/main/resources/META-INF/services/io.flamingock.api.template.ChangeTemplate b/src/main/resources/META-INF/services/io.flamingock.api.template.ChangeTemplate index b662deb..31e33a9 100644 --- a/src/main/resources/META-INF/services/io.flamingock.api.template.ChangeTemplate +++ b/src/main/resources/META-INF/services/io.flamingock.api.template.ChangeTemplate @@ -1 +1 @@ -io.flamingock.template.mongodb.MongoChangeTemplate \ No newline at end of file +io.flamingock.template.mongodb.MongoChangeTemplate diff --git a/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java b/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java index 43aa422..3bd39af 100644 --- a/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java +++ b/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java @@ -30,6 +30,7 @@ import java.util.Arrays; import java.util.HashMap; +import java.util.Map; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -47,7 +48,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -@EnableFlamingock(configFile = "flamingock/pipeline.yaml") +@EnableFlamingock(configFile = "flamingock/pipeline.yaml", strictTemplateValidation = false) @Testcontainers class MongoChangeTemplateTest { @@ -77,6 +78,8 @@ void setupEach() { mongoDatabase.getCollection("users").drop(); mongoDatabase.getCollection("products").drop(); mongoDatabase.getCollection("rollbackTestCollection").drop(); + mongoDatabase.getCollection("stepTestCollection").drop(); + mongoDatabase.getCollection("stepRollbackTest").drop(); } @@ -96,7 +99,7 @@ void happyPath() { .find() .into(new ArrayList<>()); - assertEquals(8, auditLog.size()); + assertEquals(10, auditLog.size()); assertEquals("create-users-collection-with-index", auditLog.get(0).getString("changeId")); assertEquals(AuditEntry.Status.STARTED.name(), auditLog.get(0).getString("state")); @@ -118,6 +121,11 @@ void happyPath() { assertEquals("apply-and-rollback-test", auditLog.get(7).getString("changeId")); assertEquals(AuditEntry.Status.APPLIED.name(), auditLog.get(7).getString("state")); + assertEquals("step-based-change", auditLog.get(8).getString("changeId")); + assertEquals(AuditEntry.Status.STARTED.name(), auditLog.get(8).getString("state")); + assertEquals("step-based-change", auditLog.get(9).getString("changeId")); + assertEquals(AuditEntry.Status.APPLIED.name(), auditLog.get(9).getString("state")); + // Verify for single operation List users = mongoDatabase.getCollection("users") .find() @@ -162,58 +170,46 @@ void happyPath() { boolean nameIndexExists = rollbackIndexes.stream() .anyMatch(idx -> "name_index".equals(idx.getString("name"))); assertTrue(nameIndexExists, "Name index should exist on rollbackTestCollection"); + + // Verify step-based change + List stepItems = mongoDatabase.getCollection("stepTestCollection") + .find() + .into(new ArrayList<>()); + assertEquals(2, stepItems.size(), "Should have 2 items in stepTestCollection"); + assertEquals("StepItem1", stepItems.get(0).getString("name")); + assertEquals("StepItem2", stepItems.get(1).getString("name")); + + List stepIndexes = mongoDatabase.getCollection("stepTestCollection") + .listIndexes() + .into(new ArrayList<>()); + boolean stepNameIndexExists = stepIndexes.stream() + .anyMatch(idx -> "step_name_index".equals(idx.getString("name"))); + assertTrue(stepNameIndexExists, "Name index should exist on stepTestCollection"); } @Test - @DisplayName("WHEN rollback is invoked THEN multiple rollback operations execute in defined order") - void rollbackWithMultipleOperations() { + @DisplayName("WHEN rollback is invoked with a single operation THEN rollback operation executes") + void rollbackWithSingleOperation() { // First, set up the state by creating the collection and inserting data mongoDatabase.createCollection("rollbackTestCollection"); mongoDatabase.getCollection("rollbackTestCollection").insertMany(Arrays.asList( new Document("name", "Item1").append("value", 100), new Document("name", "Item2").append("value", 200) )); - mongoDatabase.getCollection("rollbackTestCollection").createIndex( - new Document("name", 1), - new com.mongodb.client.model.IndexOptions().name("name_index").unique(true) - ); assertTrue(collectionExists("rollbackTestCollection"), "Collection should exist before rollback"); assertEquals(2, mongoDatabase.getCollection("rollbackTestCollection").countDocuments(), "Should have 2 documents before rollback"); MongoChangeTemplate template = new MongoChangeTemplate(); - template.setChangeId("apply-and-rollback-test"); + template.setChangeId("rollback-test"); template.setTransactional(false); - // Set rollback payload - should execute in order: - // 1. Drop index - // 2. Delete all documents - // 3. Drop collection - List rollbackOps = new ArrayList<>(); - - MongoOperation dropIndexOp = new MongoOperation(); - dropIndexOp.setType("dropIndex"); - dropIndexOp.setCollection("rollbackTestCollection"); - HashMap dropIndexParams = new HashMap<>(); - dropIndexParams.put("indexName", "name_index"); - dropIndexOp.setParameters(dropIndexParams); - rollbackOps.add(dropIndexOp); - - MongoOperation deleteOp = new MongoOperation(); - deleteOp.setType("delete"); - deleteOp.setCollection("rollbackTestCollection"); - HashMap deleteParams = new HashMap<>(); - deleteParams.put("filter", new HashMap<>()); - deleteOp.setParameters(deleteParams); - rollbackOps.add(deleteOp); - + // Set rollback payload - drop collection MongoOperation dropCollectionOp = new MongoOperation(); dropCollectionOp.setType("dropCollection"); dropCollectionOp.setCollection("rollbackTestCollection"); - rollbackOps.add(dropCollectionOp); - - template.setRollbackPayload(rollbackOps); + template.setRollbackPayload(dropCollectionOp); template.rollback(mongoDatabase, null); @@ -227,4 +223,78 @@ private boolean collectionExists(String collectionName) { .contains(collectionName); } + @Test + @DisplayName("WHEN apply is invoked with a single operation THEN operation executes successfully") + void singleOperationApplySuccess() { + MongoChangeTemplate template = new MongoChangeTemplate(); + template.setChangeId("apply-test"); + template.setTransactional(false); + + // Set apply payload - create collection + MongoOperation createCollectionOp = new MongoOperation(); + createCollectionOp.setType("createCollection"); + createCollectionOp.setCollection("stepRollbackTest"); + template.setApplyPayload(createCollectionOp); + + template.apply(mongoDatabase, null); + + assertTrue(collectionExists("stepRollbackTest"), "Collection should exist after apply"); + } + + @Test + @DisplayName("WHEN apply is invoked with insert operation THEN documents are inserted") + void insertOperationApplySuccess() { + mongoDatabase.createCollection("stepRollbackTest"); + + MongoChangeTemplate template = new MongoChangeTemplate(); + template.setChangeId("insert-test"); + template.setTransactional(false); + + // Set apply payload - insert documents + MongoOperation insertOp = new MongoOperation(); + insertOp.setType("insert"); + insertOp.setCollection("stepRollbackTest"); + Map params = new HashMap<>(); + List> docs = new ArrayList<>(); + Map doc1 = new HashMap<>(); + doc1.put("name", "Test1"); + doc1.put("value", 100); + docs.add(doc1); + params.put("documents", docs); + insertOp.setParameters(params); + template.setApplyPayload(insertOp); + + template.apply(mongoDatabase, null); + + assertEquals(1, mongoDatabase.getCollection("stepRollbackTest").countDocuments(), + "Should have 1 document after apply"); + } + + @Test + @DisplayName("WHEN framework triggers rollback for a single operation THEN it is rolled back") + void frameworkTriggeredRollbackForSingleOperation() { + // Set up the state as if the apply had been executed + mongoDatabase.createCollection("stepRollbackTest"); + mongoDatabase.getCollection("stepRollbackTest").insertMany(Arrays.asList( + new Document("name", "Item1"), + new Document("name", "Item2") + )); + + assertTrue(collectionExists("stepRollbackTest"), "Collection should exist before rollback"); + + MongoChangeTemplate template = new MongoChangeTemplate(); + template.setChangeId("framework-rollback-test"); + template.setTransactional(false); + + // Set rollback payload - drop collection + MongoOperation dropCollectionOp = new MongoOperation(); + dropCollectionOp.setType("dropCollection"); + dropCollectionOp.setCollection("stepRollbackTest"); + template.setRollbackPayload(dropCollectionOp); + + template.rollback(mongoDatabase, null); + + assertFalse(collectionExists("stepRollbackTest"), + "Collection should not exist after framework-triggered rollback"); + } } diff --git a/src/test/java/io/flamingock/template/mongodb/changes/_0001__create_users_collections.yaml b/src/test/java/io/flamingock/template/mongodb/changes/_0001__create_users_collections.yaml index c071f1d..cba0016 100644 --- a/src/test/java/io/flamingock/template/mongodb/changes/_0001__create_users_collections.yaml +++ b/src/test/java/io/flamingock/template/mongodb/changes/_0001__create_users_collections.yaml @@ -3,6 +3,10 @@ transactional: false template: MongoChangeTemplate targetSystem: id: "mongodb" -apply: - - type: createCollection - collection: users +steps: + - apply: + type: createCollection + collection: users + rollback: + type: dropCollection + collection: users diff --git a/src/test/java/io/flamingock/template/mongodb/changes/_0002__seed_users.yaml b/src/test/java/io/flamingock/template/mongodb/changes/_0002__seed_users.yaml index b6f9ab5..09807d3 100644 --- a/src/test/java/io/flamingock/template/mongodb/changes/_0002__seed_users.yaml +++ b/src/test/java/io/flamingock/template/mongodb/changes/_0002__seed_users.yaml @@ -3,14 +3,20 @@ transactional: true template: MongoChangeTemplate targetSystem: id: "mongodb" -apply: - - type: insert - collection: users - parameters: - documents: - - name: "Admin" - email: "admin@company.com" - roles: [ "superuser" ] - - name: "Backup" - email: "backup@company.com" - roles: [ "readonly" ] +steps: + - apply: + type: insert + collection: users + parameters: + documents: + - name: "Admin" + email: "admin@company.com" + roles: [ "superuser" ] + - name: "Backup" + email: "backup@company.com" + roles: [ "readonly" ] + rollback: + type: delete + collection: users + parameters: + filter: {} diff --git a/src/test/java/io/flamingock/template/mongodb/changes/_0003__multiple_operations.yaml b/src/test/java/io/flamingock/template/mongodb/changes/_0003__multiple_operations.yaml index 53752a6..33cac6c 100644 --- a/src/test/java/io/flamingock/template/mongodb/changes/_0003__multiple_operations.yaml +++ b/src/test/java/io/flamingock/template/mongodb/changes/_0003__multiple_operations.yaml @@ -3,28 +3,45 @@ transactional: false template: MongoChangeTemplate targetSystem: id: "mongodb" -apply: - - type: createCollection - collection: products - - type: insert - collection: products - parameters: - documents: - - name: "Laptop" - price: 999.99 - category: "Electronics" - - name: "Keyboard" - price: 79.99 - category: "Electronics" - - name: "Mouse" - price: 29.99 - category: "Electronics" +steps: + - apply: + type: createCollection + collection: products + rollback: + type: dropCollection + collection: products - - type: createIndex - collection: products - parameters: - keys: - category: 1 - options: - name: "category_index" + - apply: + type: insert + collection: products + parameters: + documents: + - name: "Laptop" + price: 999.99 + category: "Electronics" + - name: "Keyboard" + price: 79.99 + category: "Electronics" + - name: "Mouse" + price: 29.99 + category: "Electronics" + rollback: + type: delete + collection: products + parameters: + filter: {} + + - apply: + type: createIndex + collection: products + parameters: + keys: + category: 1 + options: + name: "category_index" + rollback: + type: dropIndex + collection: products + parameters: + indexName: "category_index" diff --git a/src/test/java/io/flamingock/template/mongodb/changes/_0004__apply_and_rollback.yaml b/src/test/java/io/flamingock/template/mongodb/changes/_0004__apply_and_rollback.yaml index cb3826f..7d6675f 100644 --- a/src/test/java/io/flamingock/template/mongodb/changes/_0004__apply_and_rollback.yaml +++ b/src/test/java/io/flamingock/template/mongodb/changes/_0004__apply_and_rollback.yaml @@ -3,38 +3,41 @@ transactional: false template: MongoChangeTemplate targetSystem: id: "mongodb" -apply: - - type: createCollection - collection: rollbackTestCollection - - type: insert - collection: rollbackTestCollection - parameters: - documents: - - name: "Item1" - value: 100 - - name: "Item2" - value: 200 +steps: + - apply: + type: createCollection + collection: rollbackTestCollection + rollback: + type: dropCollection + collection: rollbackTestCollection - - type: createIndex - collection: rollbackTestCollection - parameters: - keys: - name: 1 - options: - name: "name_index" - unique: true + - apply: + type: insert + collection: rollbackTestCollection + parameters: + documents: + - name: "Item1" + value: 100 + - name: "Item2" + value: 200 + rollback: + type: delete + collection: rollbackTestCollection + parameters: + filter: {} -rollback: - - type: dropIndex - collection: rollbackTestCollection - parameters: - indexName: "name_index" - - - type: delete - collection: rollbackTestCollection - parameters: - filter: {} - - - type: dropCollection - collection: rollbackTestCollection + - apply: + type: createIndex + collection: rollbackTestCollection + parameters: + keys: + name: 1 + options: + name: "name_index" + unique: true + rollback: + type: dropIndex + collection: rollbackTestCollection + parameters: + indexName: "name_index" diff --git a/src/test/java/io/flamingock/template/mongodb/changes/_0005__step_based_change.yaml b/src/test/java/io/flamingock/template/mongodb/changes/_0005__step_based_change.yaml new file mode 100644 index 0000000..1ee1814 --- /dev/null +++ b/src/test/java/io/flamingock/template/mongodb/changes/_0005__step_based_change.yaml @@ -0,0 +1,43 @@ +id: step-based-change +transactional: false +template: MongoChangeTemplate +targetSystem: + id: "mongodb" + +steps: + - apply: + type: createCollection + collection: stepTestCollection + rollback: + type: dropCollection + collection: stepTestCollection + + - apply: + type: insert + collection: stepTestCollection + parameters: + documents: + - name: "StepItem1" + value: 100 + - name: "StepItem2" + value: 200 + rollback: + type: delete + collection: stepTestCollection + parameters: + filter: {} + + - apply: + type: createIndex + collection: stepTestCollection + parameters: + keys: + name: 1 + options: + name: "step_name_index" + unique: true + rollback: + type: dropIndex + collection: stepTestCollection + parameters: + indexName: "step_name_index"