From 4c1a0e6afc3c2dd6351cc3dae20ff8e4be180635 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Mon, 26 Jan 2026 07:58:13 +0000 Subject: [PATCH 1/8] feat: add steps to template --- .../template/mongodb/MongoChangeTemplate.java | 346 +++++++++++++++++- .../MongoStepExecutionException.java | 109 ++++++ .../template/mongodb/model/MongoStep.java | 128 +++++++ .../mongodb/model/MongoStepsPayload.java | 112 ++++++ .../validation/MongoOperationValidator.java | 63 ++++ .../mongodb/MongoChangeTemplateTest.java | 293 ++++++++++++++- .../changes/_0005__step_based_change.yaml | 43 +++ 7 files changed, 1079 insertions(+), 15 deletions(-) create mode 100644 src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java create mode 100644 src/main/java/io/flamingock/template/mongodb/model/MongoStep.java create mode 100644 src/main/java/io/flamingock/template/mongodb/model/MongoStepsPayload.java create mode 100644 src/test/java/io/flamingock/template/mongodb/changes/_0005__step_based_change.yaml diff --git a/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java index 8b17922..807a0b3 100644 --- a/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java +++ b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java @@ -21,20 +21,57 @@ import io.flamingock.api.annotations.Nullable; import io.flamingock.api.annotations.Rollback; import io.flamingock.api.template.AbstractChangeTemplate; +import io.flamingock.template.mongodb.exception.MongoStepExecutionException; import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.MongoStep; 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.Collections; import java.util.List; import java.util.Map; /** * MongoDB Change Template for executing declarative MongoDB operations defined in YAML. * - *

YAML Structure

+ *

Supported YAML Structures

+ * + *

Step-Based Format (Recommended)

+ *

Each step contains an apply operation and optional rollback operation. + * When a step fails, all previously successful steps are rolled back in reverse order.

+ *
{@code
+ * id: create-orders-collection
+ * 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"
+ *     rollback:
+ *       type: delete
+ *       collection: orders
+ *       parameters:
+ *         filter: {}
+ * }
+ * + *

Legacy Format

+ *

Separate apply and rollback operation lists. The framework handles rollback invocation on failure.

*
{@code
  * id: create-orders-collection
  * transactional: true
@@ -49,7 +86,6 @@
  *     parameters:
  *       documents:
  *         - orderId: "ORD-001"
- *           customer: "John Doe"
  * rollback:
  *   - type: delete
  *     collection: orders
@@ -61,18 +97,21 @@
  *
  * 

Execution Behavior

* * * @see MongoOperation + * @see MongoStep */ /* * Backward Compatibility for YAML Formats * - * This template supports two YAML structures: + * This template supports three YAML structures: + * - New step format: steps: [- apply: {...}, rollback: {...}] * - New list format: apply: [- type: createCollection, - type: insert] * - Old single format: apply: {type: createCollection, collection: users} * @@ -84,12 +123,25 @@ * the framework to pass raw YAML data (Map or List). The convertRawPayload() * method then handles conversion to List for both formats. * + * For steps format, the framework populates the stepsPayload field from the + * 'steps' key in the YAML. + * * The converted operations are cached to avoid repeated conversion. */ public class MongoChangeTemplate extends AbstractChangeTemplate, List> { + private static final Logger log = LoggerFactory.getLogger(MongoChangeTemplate.class); + + /** + * Steps payload populated by the framework from the 'steps' key in YAML. + * This field is set via reflection by the Flamingock framework. + */ + protected List stepsPayload; + private List convertedApplyOps; private List convertedRollbackOps; + private List convertedSteps; + private Boolean isStepsFormat; public MongoChangeTemplate() { super(MongoOperation.class); @@ -98,7 +150,7 @@ public MongoChangeTemplate() { /** * 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. + * with the old single-operation YAML format and the new steps format. */ @Override @SuppressWarnings("unchecked") @@ -117,20 +169,135 @@ public Class> getRollbackPayloadClass() { return (Class>) (Class) Object.class; } + /** + * Sets the steps payload. Called by the framework when 'steps' key is present in YAML. + * Accepts Object to handle raw YAML data (List of Maps) and converts it to List of MongoStep. + * + * @param stepsPayload the steps from the YAML (can be raw List of Maps or List of MongoStep) + */ + @SuppressWarnings("unchecked") + public void setStepsPayload(Object stepsPayload) { + if (stepsPayload == null) { + this.stepsPayload = null; + return; + } + + if (stepsPayload instanceof List) { + List list = (List) stepsPayload; + if (list.isEmpty()) { + this.stepsPayload = new ArrayList<>(); + return; + } + + Object firstElement = list.get(0); + if (firstElement instanceof MongoStep) { + // Already converted + this.stepsPayload = (List) stepsPayload; + } else if (firstElement instanceof Map) { + // Raw YAML data - convert to MongoStep list + this.stepsPayload = convertListToSteps(list); + } else { + this.stepsPayload = new ArrayList<>(); + } + } else { + this.stepsPayload = new ArrayList<>(); + } + } + + /** + * Returns the steps payload. + * + * @return the steps payload, or null if not using steps format + */ + public List getStepsPayload() { + return stepsPayload; + } + @Apply public void apply(MongoDatabase db, @Nullable ClientSession clientSession) { validateSession(clientSession); - List operations = getConvertedApplyOperations(); - validatePayload(operations, changeId + ".apply"); - executeOperations(db, operations, clientSession); + + if (isStepsFormat()) { + List steps = getConvertedSteps(); + validateStepsPayload(steps); + executeStepsWithRollback(db, steps, clientSession); + } else { + 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); + + if (isStepsFormat()) { + // Framework-triggered rollback: rollback all steps in reverse order + List steps = getConvertedSteps(); + if (steps != null && !steps.isEmpty()) { + rollbackSteps(db, steps, clientSession); + } + } else { + // Legacy format: execute rollback operations as before + List operations = getConvertedRollbackOperations(); + validatePayload(operations, changeId + ".rollback"); + executeOperations(db, operations, clientSession); + } + } + + /** + * Determines if the payload uses the step-based format. + * + * @return true if using steps format, false for legacy format + */ + private boolean isStepsFormat() { + if (isStepsFormat == null) { + // First check if stepsPayload is directly set (new YAML format with 'steps' key) + if (stepsPayload != null && !stepsPayload.isEmpty()) { + isStepsFormat = true; + return isStepsFormat; + } + + // Fall back to checking applyPayload for step-format data (backward compatibility) + Object rawPayload = getRawPayload("applyPayload"); + isStepsFormat = detectStepsFormat(rawPayload); + } + return isStepsFormat; + } + + @SuppressWarnings("unchecked") + private boolean detectStepsFormat(Object rawPayload) { + if (rawPayload == null) { + return false; + } + + // Check if it's a list where first element has "apply" key (steps format) + if (rawPayload instanceof List && !((List) rawPayload).isEmpty()) { + Object firstElement = ((List) rawPayload).get(0); + if (firstElement instanceof Map) { + Map map = (Map) firstElement; + return map.containsKey("apply"); + } + // Check if it's already a list of MongoStep + if (firstElement instanceof MongoStep) { + return true; + } + } + return false; + } + + private List getConvertedSteps() { + if (convertedSteps == null) { + // First try to get from stepsPayload (new format with 'steps' key) + if (stepsPayload != null && !stepsPayload.isEmpty()) { + convertedSteps = stepsPayload; + } else { + // Fall back to applyPayload (backward compatibility for step-format in apply) + convertedSteps = convertToSteps(getRawPayload("applyPayload")); + } + } + return convertedSteps; } private List getConvertedApplyOperations() { @@ -198,6 +365,17 @@ private void validatePayload(List operations, String entityId) { } } + private void validateStepsPayload(List steps) { + if (steps == null || steps.isEmpty()) { + return; + } + + List errors = MongoOperationValidator.validateSteps(steps, changeId); + if (!errors.isEmpty()) { + throw new MongoTemplateValidationException(errors); + } + } + private void executeOperations(MongoDatabase db, List operations, ClientSession clientSession) { if (operations == null || operations.isEmpty()) { return; @@ -208,6 +386,146 @@ private void executeOperations(MongoDatabase db, List operations } } + /** + * Executes steps with automatic rollback on failure. + * + *

When a step fails, all previously successful steps are rolled back + * in reverse order. Rollback errors are logged but don't stop the rollback process.

+ * + * @param db the MongoDB database + * @param steps the list of steps to execute + * @param clientSession the client session (may be null) + * @throws MongoStepExecutionException if a step fails + */ + private void executeStepsWithRollback(MongoDatabase db, List steps, ClientSession clientSession) { + if (steps == null || steps.isEmpty()) { + return; + } + + List completedSteps = new ArrayList<>(); + + for (int i = 0; i < steps.size(); i++) { + MongoStep step = steps.get(i); + int stepNumber = i + 1; // 1-based indexing for user-friendly messages + + try { + log.debug("Executing step {} apply operation", stepNumber); + step.getApply().getOperator(db).apply(clientSession); + completedSteps.add(step); + } catch (Exception e) { + log.error("Step {} failed: {}", stepNumber, e.getMessage()); + + // Rollback completed steps in reverse order + if (!completedSteps.isEmpty()) { + log.info("Rolling back {} completed step(s)", completedSteps.size()); + rollbackSteps(db, completedSteps, clientSession); + } + + throw new MongoStepExecutionException( + e.getMessage(), + stepNumber, + new ArrayList<>(completedSteps), + e + ); + } + } + } + + /** + * Rolls back steps in reverse order. + * + *

Steps without rollback operations are skipped. Rollback errors are logged + * but don't stop the rollback process for remaining steps.

+ * + * @param db the MongoDB database + * @param steps the list of steps to rollback + * @param clientSession the client session (may be null) + */ + private void rollbackSteps(MongoDatabase db, List steps, ClientSession clientSession) { + if (steps == null || steps.isEmpty()) { + return; + } + + List rollbackErrors = new ArrayList<>(); + List reversedSteps = new ArrayList<>(steps); + Collections.reverse(reversedSteps); + + for (int i = 0; i < reversedSteps.size(); i++) { + MongoStep step = reversedSteps.get(i); + int originalStepNumber = steps.size() - i; // Original 1-based step number + + if (step.hasRollback()) { + try { + log.debug("Executing rollback for step {}", originalStepNumber); + step.getRollback().getOperator(db).apply(clientSession); + } catch (Exception e) { + log.error("Rollback failed for step {}: {}", originalStepNumber, e.getMessage()); + rollbackErrors.add(e); + } + } else { + log.debug("Step {} has no rollback operation, skipping", originalStepNumber); + } + } + + if (!rollbackErrors.isEmpty()) { + log.warn("{} rollback operation(s) failed", rollbackErrors.size()); + } + } + + @SuppressWarnings("unchecked") + private List convertToSteps(Object rawPayload) { + if (rawPayload == null) { + return new ArrayList<>(); + } + + // Handle if it's already a list of MongoStep + if (rawPayload instanceof List && !((List) rawPayload).isEmpty()) { + Object firstElement = ((List) rawPayload).get(0); + if (firstElement instanceof MongoStep) { + return (List) rawPayload; + } + } + + // Handle direct List of step maps + if (rawPayload instanceof List) { + List list = (List) rawPayload; + if (!list.isEmpty()) { + Object firstElement = list.get(0); + if (firstElement instanceof Map && ((Map) firstElement).containsKey("apply")) { + return convertListToSteps(list); + } + } + } + + return new ArrayList<>(); + } + + @SuppressWarnings("unchecked") + private List convertListToSteps(List stepsList) { + List steps = new ArrayList<>(); + + for (Object item : stepsList) { + if (item instanceof Map) { + Map stepMap = (Map) item; + MongoStep step = new MongoStep(); + + Object applyValue = stepMap.get("apply"); + if (applyValue instanceof Map) { + step.setApply(convertMapToOperation((Map) applyValue)); + } + + Object rollbackValue = stepMap.get("rollback"); + if (rollbackValue instanceof Map) { + step.setRollback(convertMapToOperation((Map) rollbackValue)); + } + + steps.add(step); + } + } + + return steps; + } + @SuppressWarnings("unchecked") private List convertRawPayload(Object rawPayload) { if (rawPayload == null) { diff --git a/src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java b/src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java new file mode 100644 index 0000000..3214437 --- /dev/null +++ b/src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java @@ -0,0 +1,109 @@ +/* + * Copyright 2025 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.template.mongodb.exception; + +import io.flamingock.template.mongodb.model.MongoStep; + +import java.util.Collections; +import java.util.List; + +/** + * Exception thrown when a step execution fails during apply or rollback. + * + *

This exception provides context about which step failed (using 1-based indexing + * for user-friendly error messages) and which steps were successfully completed + * before the failure.

+ * + *

Error Information

+ *
    + *
  • stepNumber - The 1-based index of the failed step
  • + *
  • completedSteps - List of steps that completed successfully before the failure
  • + *
  • cause - The original exception that caused the failure
  • + *
+ * + *

Example

+ *
{@code
+ * try {
+ *     executeSteps(steps);
+ * } catch (MongoStepExecutionException e) {
+ *     System.out.println("Step " + e.getStepNumber() + " failed: " + e.getMessage());
+ *     System.out.println("Completed " + e.getCompletedSteps().size() + " steps before failure");
+ * }
+ * }
+ */ +public class MongoStepExecutionException extends RuntimeException { + + private final int stepNumber; + private final List completedSteps; + + /** + * Creates a new step execution exception. + * + * @param message the error message + * @param stepNumber the 1-based index of the failed step + * @param completedSteps the list of steps that completed before the failure + * @param cause the original exception + */ + public MongoStepExecutionException(String message, int stepNumber, List completedSteps, Throwable cause) { + super(formatMessage(message, stepNumber), cause); + this.stepNumber = stepNumber; + this.completedSteps = completedSteps != null + ? Collections.unmodifiableList(completedSteps) + : Collections.emptyList(); + } + + /** + * Creates a new step execution exception without a cause. + * + * @param message the error message + * @param stepNumber the 1-based index of the failed step + * @param completedSteps the list of steps that completed before the failure + */ + public MongoStepExecutionException(String message, int stepNumber, List completedSteps) { + this(message, stepNumber, completedSteps, null); + } + + /** + * Returns the 1-based index of the step that failed. + * + * @return the step number (1-based) + */ + public int getStepNumber() { + return stepNumber; + } + + /** + * Returns the list of steps that completed successfully before the failure. + * + * @return unmodifiable list of completed steps + */ + public List getCompletedSteps() { + return completedSteps; + } + + /** + * Returns the number of steps that completed before the failure. + * + * @return the count of completed steps + */ + public int getCompletedStepCount() { + return completedSteps.size(); + } + + private static String formatMessage(String message, int stepNumber) { + return String.format("Step %d failed: %s", stepNumber, message); + } +} diff --git a/src/main/java/io/flamingock/template/mongodb/model/MongoStep.java b/src/main/java/io/flamingock/template/mongodb/model/MongoStep.java new file mode 100644 index 0000000..c53b4e4 --- /dev/null +++ b/src/main/java/io/flamingock/template/mongodb/model/MongoStep.java @@ -0,0 +1,128 @@ +/* + * Copyright 2025 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.template.mongodb.model; + +import io.flamingock.api.NonLockGuardedType; +import io.flamingock.api.annotations.NonLockGuarded; + +/** + * Represents a single step in a step-based MongoDB change template. + * + *

Each step contains an {@code apply} operation that executes during the forward + * migration, and an optional {@code rollback} operation that executes if the step + * or a subsequent step fails.

+ * + *

YAML Structure

+ *
{@code
+ * steps:
+ *   - apply:
+ *       type: createCollection
+ *       collection: users
+ *     rollback:
+ *       type: dropCollection
+ *       collection: users
+ *   - apply:
+ *       type: insert
+ *       collection: users
+ *       parameters:
+ *         documents:
+ *           - name: "John"
+ *     rollback:
+ *       type: delete
+ *       collection: users
+ *       parameters:
+ *         filter: {}
+ * }
+ * + *

Rollback Behavior

+ *
    + *
  • Rollback is optional - steps without rollback are skipped during rollback
  • + *
  • When a step fails, all previously successful steps are rolled back in reverse order
  • + *
  • Rollback errors are logged but don't stop the rollback process
  • + *
+ * + * @see MongoOperation + */ +@NonLockGuarded(NonLockGuardedType.NONE) +public class MongoStep { + + private MongoOperation apply; + private MongoOperation rollback; + + public MongoStep() { + } + + public MongoStep(MongoOperation apply, MongoOperation rollback) { + this.apply = apply; + this.rollback = rollback; + } + + /** + * Returns the apply operation for this step. + * + * @return the apply operation (required) + */ + public MongoOperation getApply() { + return apply; + } + + /** + * Sets the apply operation for this step. + * + * @param apply the apply operation + */ + public void setApply(MongoOperation apply) { + this.apply = apply; + } + + /** + * Returns the rollback operation for this step. + * + * @return the rollback operation, or null if no rollback is defined + */ + public MongoOperation getRollback() { + return rollback; + } + + /** + * Sets the rollback operation for this step. + * + * @param rollback the rollback operation (optional) + */ + public void setRollback(MongoOperation rollback) { + this.rollback = rollback; + } + + /** + * Checks if this step has a rollback operation defined. + * + * @return true if a rollback operation is defined + */ + public boolean hasRollback() { + return rollback != null; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder("MongoStep{"); + sb.append("apply=").append(apply); + if (rollback != null) { + sb.append(", rollback=").append(rollback); + } + sb.append('}'); + return sb.toString(); + } +} diff --git a/src/main/java/io/flamingock/template/mongodb/model/MongoStepsPayload.java b/src/main/java/io/flamingock/template/mongodb/model/MongoStepsPayload.java new file mode 100644 index 0000000..7baac5e --- /dev/null +++ b/src/main/java/io/flamingock/template/mongodb/model/MongoStepsPayload.java @@ -0,0 +1,112 @@ +/* + * Copyright 2025 Flamingock (https://www.flamingock.io) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.flamingock.template.mongodb.model; + +import io.flamingock.api.NonLockGuardedType; +import io.flamingock.api.annotations.NonLockGuarded; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Wrapper for a list of MongoDB steps in the step-based change template format. + * + *

This payload represents the new step-based YAML structure where each step + * contains an apply operation and an optional rollback operation.

+ * + *

YAML Structure

+ *
{@code
+ * id: my-change
+ * template: MongoChangeTemplate
+ * targetSystem:
+ *   id: mongodb
+ * steps:
+ *   - apply:
+ *       type: createCollection
+ *       collection: users
+ *     rollback:
+ *       type: dropCollection
+ *       collection: users
+ * }
+ * + * @see MongoStep + */ +@NonLockGuarded(NonLockGuardedType.NONE) +public class MongoStepsPayload { + + private List steps; + + public MongoStepsPayload() { + this.steps = new ArrayList<>(); + } + + public MongoStepsPayload(List steps) { + this.steps = steps != null ? new ArrayList<>(steps) : new ArrayList<>(); + } + + /** + * Returns the list of steps in this payload. + * + * @return the list of steps (never null) + */ + public List getSteps() { + return steps; + } + + /** + * Sets the list of steps for this payload. + * + * @param steps the list of steps + */ + public void setSteps(List steps) { + this.steps = steps != null ? steps : new ArrayList<>(); + } + + /** + * Returns the number of steps in this payload. + * + * @return the number of steps + */ + public int size() { + return steps.size(); + } + + /** + * Checks if this payload has no steps. + * + * @return true if there are no steps + */ + public boolean isEmpty() { + return steps.isEmpty(); + } + + /** + * Returns an unmodifiable view of the steps list. + * + * @return unmodifiable list of steps + */ + public List getStepsUnmodifiable() { + return Collections.unmodifiableList(steps); + } + + @Override + public String toString() { + return "MongoStepsPayload{" + + "steps=" + steps + + '}'; + } +} 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..efecff0 100644 --- a/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java +++ b/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java @@ -17,6 +17,7 @@ import io.flamingock.template.mongodb.model.MongoOperation; import io.flamingock.template.mongodb.model.MongoOperationType; +import io.flamingock.template.mongodb.model.MongoStep; import java.util.ArrayList; import java.util.List; @@ -417,4 +418,66 @@ private static List validateCreateView(MongoOperation op, Strin return errors; } + + /** + * Validates a single MongoDB step. + * + *

Validates both the apply operation (required) and the rollback operation (optional). + * If rollback is present, it must be valid.

+ * + * @param step the step to validate + * @param entityId the identifier for error reporting (e.g., "changeId.steps[0]") + * @return list of validation errors (empty if valid) + */ + public static List validateStep(MongoStep step, String entityId) { + List errors = new ArrayList<>(); + + if (step == null) { + errors.add(new ValidationError(entityId, "MongoStep", "Step cannot be null")); + return errors; + } + + // Validate apply operation (required) + if (step.getApply() == null) { + errors.add(new ValidationError(entityId, "MongoStep", "Step requires an 'apply' operation")); + } else { + errors.addAll(validate(step.getApply(), entityId + ".apply")); + } + + // Validate rollback operation (optional, but if present must be valid) + if (step.getRollback() != null) { + errors.addAll(validate(step.getRollback(), entityId + ".rollback")); + } + + return errors; + } + + /** + * Validates a list of MongoDB steps. + * + * @param steps the list of steps to validate + * @param entityId the base identifier for error reporting (e.g., "changeId") + * @return list of validation errors (empty if all steps are valid) + */ + public static List validateSteps(List steps, String entityId) { + List errors = new ArrayList<>(); + + if (steps == null) { + errors.add(new ValidationError(entityId, "MongoSteps", "Steps list cannot be null")); + return errors; + } + + if (steps.isEmpty()) { + errors.add(new ValidationError(entityId, "MongoSteps", "Steps list cannot be empty")); + return errors; + } + + for (int i = 0; i < steps.size(); i++) { + // Use 1-based indexing for user-friendly error messages + String stepEntityId = entityId + ".steps[" + (i + 1) + "]"; + errors.addAll(validateStep(steps.get(i), stepEntityId)); + } + + return errors; + } } diff --git a/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java b/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java index 43aa422..79ccea2 100644 --- a/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java +++ b/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java @@ -25,11 +25,13 @@ import io.flamingock.internal.common.core.audit.AuditEntry; import io.flamingock.internal.core.builder.FlamingockFactory; import io.flamingock.targetsystem.mongodb.sync.MongoDBSyncTargetSystem; +import io.flamingock.template.mongodb.exception.MongoStepExecutionException; import io.flamingock.template.mongodb.model.MongoOperation; import org.bson.Document; 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; @@ -45,6 +47,7 @@ import static io.flamingock.internal.util.constants.CommunityPersistenceConstants.DEFAULT_AUDIT_STORE_NAME; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @EnableFlamingock(configFile = "flamingock/pipeline.yaml") @@ -77,6 +80,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 +101,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 +123,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,6 +172,21 @@ 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 @@ -227,4 +252,270 @@ private boolean collectionExists(String collectionName) { .contains(collectionName); } + @Test + @DisplayName("WHEN step-based apply succeeds THEN all steps are executed") + void stepBasedApplySuccess() throws Exception { + MongoChangeTemplate template = new MongoChangeTemplate(); + template.setChangeId("step-test"); + template.setTransactional(false); + + // Create step payload programmatically + List> steps = new ArrayList<>(); + + // Step 1: Create collection + Map step1 = new HashMap<>(); + Map step1Apply = new HashMap<>(); + step1Apply.put("type", "createCollection"); + step1Apply.put("collection", "stepRollbackTest"); + Map step1Rollback = new HashMap<>(); + step1Rollback.put("type", "dropCollection"); + step1Rollback.put("collection", "stepRollbackTest"); + step1.put("apply", step1Apply); + step1.put("rollback", step1Rollback); + steps.add(step1); + + // Step 2: Insert documents + Map step2 = new HashMap<>(); + Map step2Apply = new HashMap<>(); + step2Apply.put("type", "insert"); + step2Apply.put("collection", "stepRollbackTest"); + Map step2Params = new HashMap<>(); + List> docs = new ArrayList<>(); + Map doc1 = new HashMap<>(); + doc1.put("name", "Test1"); + doc1.put("value", 100); + docs.add(doc1); + step2Params.put("documents", docs); + step2Apply.put("parameters", step2Params); + Map step2Rollback = new HashMap<>(); + step2Rollback.put("type", "delete"); + step2Rollback.put("collection", "stepRollbackTest"); + Map step2RollbackParams = new HashMap<>(); + step2RollbackParams.put("filter", new HashMap<>()); + step2Rollback.put("parameters", step2RollbackParams); + step2.put("apply", step2Apply); + step2.put("rollback", step2Rollback); + steps.add(step2); + + setRawApplyPayload(template, steps); + + template.apply(mongoDatabase, null); + + assertTrue(collectionExists("stepRollbackTest"), "Collection should exist after apply"); + assertEquals(1, mongoDatabase.getCollection("stepRollbackTest").countDocuments(), + "Should have 1 document after apply"); + } + + @Test + @DisplayName("WHEN step 2 fails THEN step 1 is rolled back") + void stepBasedRollbackOnFailure() throws Exception { + MongoChangeTemplate template = new MongoChangeTemplate(); + template.setChangeId("step-rollback-test"); + template.setTransactional(false); + + // Create step payload that will fail on step 2 + List> steps = new ArrayList<>(); + + // Step 1: Create collection (will succeed) + Map step1 = new HashMap<>(); + Map step1Apply = new HashMap<>(); + step1Apply.put("type", "createCollection"); + step1Apply.put("collection", "stepRollbackTest"); + Map step1Rollback = new HashMap<>(); + step1Rollback.put("type", "dropCollection"); + step1Rollback.put("collection", "stepRollbackTest"); + step1.put("apply", step1Apply); + step1.put("rollback", step1Rollback); + steps.add(step1); + + // Step 2: Try to create a collection that already exists (will fail) + // First create the collection to cause conflict + mongoDatabase.createCollection("conflictCollection"); + + Map step2 = new HashMap<>(); + Map step2Apply = new HashMap<>(); + step2Apply.put("type", "createCollection"); + step2Apply.put("collection", "conflictCollection"); // Already exists, will fail + step2.put("apply", step2Apply); + steps.add(step2); + + setRawApplyPayload(template, steps); + + MongoStepExecutionException exception = assertThrows(MongoStepExecutionException.class, + () -> template.apply(mongoDatabase, null)); + + assertEquals(2, exception.getStepNumber(), "Should fail at step 2"); + assertEquals(1, exception.getCompletedStepCount(), "Should have 1 completed step before failure"); + + // Collection should be dropped due to rollback of step 1 + assertFalse(collectionExists("stepRollbackTest"), + "Collection should not exist after rollback"); + + // Clean up + mongoDatabase.getCollection("conflictCollection").drop(); + } + + @Test + @DisplayName("WHEN step has no rollback THEN it is skipped during rollback") + void stepWithNoRollbackIsSkipped() throws Exception { + // First create the collection and insert data + mongoDatabase.createCollection("stepRollbackTest"); + mongoDatabase.getCollection("stepRollbackTest").insertOne(new Document("name", "Existing")); + + MongoChangeTemplate template = new MongoChangeTemplate(); + template.setChangeId("step-no-rollback-test"); + template.setTransactional(false); + + // Create step payload where step 1 has no rollback + List> steps = new ArrayList<>(); + + // Step 1: Insert (no rollback defined) + Map step1 = new HashMap<>(); + Map step1Apply = new HashMap<>(); + step1Apply.put("type", "insert"); + step1Apply.put("collection", "stepRollbackTest"); + Map step1Params = new HashMap<>(); + List> docs = new ArrayList<>(); + Map doc1 = new HashMap<>(); + doc1.put("name", "NoRollback"); + docs.add(doc1); + step1Params.put("documents", docs); + step1Apply.put("parameters", step1Params); + step1.put("apply", step1Apply); + // Note: no rollback defined for step 1 + steps.add(step1); + + // Step 2: Create collection that already exists (will fail) + mongoDatabase.createCollection("conflictCollection2"); + + Map step2 = new HashMap<>(); + Map step2Apply = new HashMap<>(); + step2Apply.put("type", "createCollection"); + step2Apply.put("collection", "conflictCollection2"); // Already exists, will fail + step2.put("apply", step2Apply); + steps.add(step2); + + setRawApplyPayload(template, steps); + + assertThrows(MongoStepExecutionException.class, + () -> template.apply(mongoDatabase, null)); + + // Data from step 1 should still exist since it had no rollback + List docs2 = mongoDatabase.getCollection("stepRollbackTest") + .find() + .into(new ArrayList<>()); + assertEquals(2, docs2.size(), "Should have original + step 1 data (step 1 has no rollback)"); + + // Clean up + mongoDatabase.getCollection("conflictCollection2").drop(); + } + + @Test + @DisplayName("WHEN framework triggers rollback for step-based change THEN all steps are rolled back") + void frameworkTriggeredRollbackForSteps() throws Exception { + // Set up the state as if steps had been applied + mongoDatabase.createCollection("stepRollbackTest"); + mongoDatabase.getCollection("stepRollbackTest").insertMany(Arrays.asList( + new Document("name", "Item1"), + new Document("name", "Item2") + )); + mongoDatabase.getCollection("stepRollbackTest").createIndex( + new Document("name", 1), + new com.mongodb.client.model.IndexOptions().name("step_index") + ); + + MongoChangeTemplate template = new MongoChangeTemplate(); + template.setChangeId("framework-rollback-test"); + template.setTransactional(false); + + // Create step payload + List> steps = new ArrayList<>(); + + // Step 1: Create collection + Map step1 = new HashMap<>(); + Map step1Apply = new HashMap<>(); + step1Apply.put("type", "createCollection"); + step1Apply.put("collection", "stepRollbackTest"); + Map step1Rollback = new HashMap<>(); + step1Rollback.put("type", "dropCollection"); + step1Rollback.put("collection", "stepRollbackTest"); + step1.put("apply", step1Apply); + step1.put("rollback", step1Rollback); + steps.add(step1); + + // Step 2: Insert documents + Map step2 = new HashMap<>(); + Map step2Apply = new HashMap<>(); + step2Apply.put("type", "insert"); + step2Apply.put("collection", "stepRollbackTest"); + Map step2Params = new HashMap<>(); + List> docs = new ArrayList<>(); + docs.add(new HashMap() {{ put("name", "Item1"); }}); + step2Params.put("documents", docs); + step2Apply.put("parameters", step2Params); + Map step2Rollback = new HashMap<>(); + step2Rollback.put("type", "delete"); + step2Rollback.put("collection", "stepRollbackTest"); + Map step2RollbackParams = new HashMap<>(); + step2RollbackParams.put("filter", new HashMap<>()); + step2Rollback.put("parameters", step2RollbackParams); + step2.put("apply", step2Apply); + step2.put("rollback", step2Rollback); + steps.add(step2); + + // Step 3: Create index + Map step3 = new HashMap<>(); + Map step3Apply = new HashMap<>(); + step3Apply.put("type", "createIndex"); + step3Apply.put("collection", "stepRollbackTest"); + Map step3Params = new HashMap<>(); + step3Params.put("keys", new HashMap() {{ put("name", 1); }}); + Map step3Options = new HashMap<>(); + step3Options.put("name", "step_index"); + step3Params.put("options", step3Options); + step3Apply.put("parameters", step3Params); + Map step3Rollback = new HashMap<>(); + step3Rollback.put("type", "dropIndex"); + step3Rollback.put("collection", "stepRollbackTest"); + Map step3RollbackParams = new HashMap<>(); + step3RollbackParams.put("indexName", "step_index"); + step3Rollback.put("parameters", step3RollbackParams); + step3.put("apply", step3Apply); + step3.put("rollback", step3Rollback); + steps.add(step3); + + setRawApplyPayload(template, steps); + + assertTrue(collectionExists("stepRollbackTest"), "Collection should exist before rollback"); + + template.rollback(mongoDatabase, null); + + // Step 3 rollback drops index, Step 2 rollback deletes docs, Step 1 rollback drops collection + assertFalse(collectionExists("stepRollbackTest"), + "Collection should not exist after framework-triggered rollback"); + } + + /** + * Helper method to set raw apply payload via reflection. + * This simulates how the framework sets the payload when loading from YAML. + */ + private void setRawApplyPayload(MongoChangeTemplate template, Object payload) throws Exception { + java.lang.reflect.Field field = findField(template.getClass(), "applyPayload"); + if (field != null) { + field.setAccessible(true); + field.set(template, payload); + } + } + + 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; + } + } 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" From 06d1a3870bb9dae059fae5734fd077c888ca1ff9 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Tue, 27 Jan 2026 15:55:48 +0000 Subject: [PATCH 2/8] refactor: template steps --- README.md | 258 +++++++---- .../template/mongodb/MongoChangeTemplate.java | 421 ++---------------- .../MongoStepExecutionException.java | 11 +- .../template/mongodb/model/MongoStep.java | 128 ------ .../mongodb/model/MongoStepsPayload.java | 112 ----- .../validation/MongoOperationValidator.java | 14 +- .../mongodb/MongoChangeTemplateTest.java | 252 +++++------ .../_0001__create_users_collections.yaml | 4 +- .../mongodb/changes/_0002__seed_users.yaml | 20 +- .../changes/_0003__multiple_operations.yaml | 63 ++- .../changes/_0004__apply_and_rollback.yaml | 67 +-- 11 files changed, 413 insertions(+), 937 deletions(-) delete mode 100644 src/main/java/io/flamingock/template/mongodb/model/MongoStep.java delete mode 100644 src/main/java/io/flamingock/template/mongodb/model/MongoStepsPayload.java diff --git a/README.md b/README.md index 1819c83..8e35bf4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ 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 +- **Steps format** — Group multiple operations with paired rollbacks for atomic changes - **Rollback support** — Optional rollback operations for reversible changes - **Transaction support** — Configurable transactional execution - **Java SPI integration** — Automatically discovered by Flamingock at runtime @@ -73,8 +74,8 @@ template: MongoChangeTemplate targetSystem: id: "mongodb" apply: - - type: createCollection - collection: users + type: createCollection + collection: users ``` ### 2. Insert seed data @@ -88,43 +89,52 @@ 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"] + type: insert + collection: users + parameters: + documents: + - name: "Admin" + email: "admin@company.com" + roles: ["superuser"] + - name: "Backup" + email: "backup@company.com" + roles: ["readonly"] ``` -### 3. Create indexes with rollback +### 3. Multiple operations with rollback using steps -**Example: `_0003__create_indexes.yaml`** +For changes that require multiple operations with paired rollbacks, use the `steps` format: + +**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 +159,12 @@ rollback: ## 📄 YAML Structure +The template supports two formats: **simple format** for single operations, and **steps format** for multiple operations with paired rollbacks. + +### Simple Format + +Use this format for single operations: + ```yaml # Required: Unique identifier for this change id: my-change-id @@ -166,19 +182,51 @@ template: MongoChangeTemplate targetSystem: id: "mongodb" -# Required: List of operations to apply +# Required: Single operation to apply apply: - - type: - collection: - parameters: - # Operation-specific parameters + type: + collection: + parameters: + # Operation-specific parameters -# Optional: List of operations to rollback +# Optional: Single rollback operation rollback: - - type: - collection: - parameters: - # Operation-specific parameters + type: + collection: + parameters: + # Operation-specific parameters +``` + +### Steps Format + +Use this format when you need multiple operations with paired rollbacks: + +```yaml +id: my-change-id +transactional: false +template: MongoChangeTemplate +targetSystem: + id: "mongodb" + +# 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: ``` --- @@ -189,87 +237,129 @@ rollback: ```yaml apply: - - type: createCollection - collection: products + type: createCollection + collection: products ``` ### Create Index ```yaml apply: - - type: createIndex - collection: products - parameters: - keys: - category: 1 - price: -1 - options: - name: "category_price_index" - background: true + type: createIndex + collection: products + parameters: + keys: + category: 1 + price: -1 + options: + name: "category_price_index" + background: true ``` ### Insert Documents ```yaml apply: - - type: insert - collection: products - parameters: - documents: - - name: "Widget" - price: 29.99 - category: "electronics" - - name: "Gadget" - price: 49.99 - category: "electronics" + type: insert + collection: products + parameters: + documents: + - name: "Widget" + price: 29.99 + category: "electronics" + - name: "Gadget" + price: 49.99 + category: "electronics" ``` ### Update Documents ```yaml apply: - - type: update - collection: products - parameters: - filter: - category: "electronics" - update: - $set: - discounted: true + type: update + collection: products + parameters: + filter: + category: "electronics" + update: + $set: + discounted: true ``` ### Delete Documents ```yaml apply: - - type: delete - collection: products - parameters: - filter: - discounted: true + type: delete + collection: products + parameters: + filter: + discounted: true ``` ### Rename Collection ```yaml apply: - - type: renameCollection - collection: oldName - parameters: - newName: newName + type: renameCollection + collection: oldName + parameters: + newName: newName ``` ### Create View ```yaml apply: - - type: createView - collection: activeUsers - parameters: - viewOn: users - pipeline: - - $match: - active: true + type: createView + collection: activeUsers + parameters: + viewOn: users + pipeline: + - $match: + active: true +``` + +### Multiple Operations with Steps + +When you need multiple operations with paired rollbacks: + +```yaml +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/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java index 807a0b3..643e1bf 100644 --- a/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java +++ b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java @@ -21,9 +21,9 @@ import io.flamingock.api.annotations.Nullable; import io.flamingock.api.annotations.Rollback; import io.flamingock.api.template.AbstractChangeTemplate; +import io.flamingock.api.template.TemplateStep; import io.flamingock.template.mongodb.exception.MongoStepExecutionException; import io.flamingock.template.mongodb.model.MongoOperation; -import io.flamingock.template.mongodb.model.MongoStep; import io.flamingock.template.mongodb.validation.MongoOperationValidator; import io.flamingock.template.mongodb.validation.MongoTemplateValidationException; import io.flamingock.template.mongodb.validation.ValidationError; @@ -31,10 +31,8 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; -import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Map; /** * MongoDB Change Template for executing declarative MongoDB operations defined in YAML. @@ -70,8 +68,8 @@ * filter: {} * }
* - *

Legacy Format

- *

Separate apply and rollback operation lists. The framework handles rollback invocation on failure.

+ *

Simple Apply/Rollback Format

+ *

Single apply and rollback operation. The framework handles rollback invocation on failure.

*
{@code
  * id: create-orders-collection
  * transactional: true
@@ -79,152 +77,42 @@
  * targetSystem:
  *   id: "mongodb"
  * apply:
- *   - type: createCollection
- *     collection: orders
- *   - type: insert
- *     collection: orders
- *     parameters:
- *       documents:
- *         - orderId: "ORD-001"
+ *   type: createCollection
+ *   collection: orders
  * rollback:
- *   - type: delete
- *     collection: orders
- *     parameters:
- *       filter: {}
- *   - type: dropCollection
- *     collection: orders
+ *   type: dropCollection
+ *   collection: orders
  * }
* *

Execution Behavior

* * * @see MongoOperation - * @see MongoStep + * @see TemplateStep */ -/* - * Backward Compatibility for YAML Formats - * - * This template supports three YAML structures: - * - New step format: steps: [- apply: {...}, rollback: {...}] - * - 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. - * - * For steps format, the framework populates the stepsPayload field from the - * 'steps' key in the YAML. - * - * The converted operations are cached to avoid repeated conversion. - */ -public class MongoChangeTemplate extends AbstractChangeTemplate, List> { +public class MongoChangeTemplate extends AbstractChangeTemplate { private static final Logger log = LoggerFactory.getLogger(MongoChangeTemplate.class); - /** - * Steps payload populated by the framework from the 'steps' key in YAML. - * This field is set via reflection by the Flamingock framework. - */ - protected List stepsPayload; - - private List convertedApplyOps; - private List convertedRollbackOps; - private List convertedSteps; - private Boolean isStepsFormat; - 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 and the new steps 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; - } - - /** - * Sets the steps payload. Called by the framework when 'steps' key is present in YAML. - * Accepts Object to handle raw YAML data (List of Maps) and converts it to List of MongoStep. - * - * @param stepsPayload the steps from the YAML (can be raw List of Maps or List of MongoStep) - */ - @SuppressWarnings("unchecked") - public void setStepsPayload(Object stepsPayload) { - if (stepsPayload == null) { - this.stepsPayload = null; - return; - } - - if (stepsPayload instanceof List) { - List list = (List) stepsPayload; - if (list.isEmpty()) { - this.stepsPayload = new ArrayList<>(); - return; - } - - Object firstElement = list.get(0); - if (firstElement instanceof MongoStep) { - // Already converted - this.stepsPayload = (List) stepsPayload; - } else if (firstElement instanceof Map) { - // Raw YAML data - convert to MongoStep list - this.stepsPayload = convertListToSteps(list); - } else { - this.stepsPayload = new ArrayList<>(); - } - } else { - this.stepsPayload = new ArrayList<>(); - } - } - - /** - * Returns the steps payload. - * - * @return the steps payload, or null if not using steps format - */ - public List getStepsPayload() { - return stepsPayload; - } - @Apply public void apply(MongoDatabase db, @Nullable ClientSession clientSession) { validateSession(clientSession); - if (isStepsFormat()) { - List steps = getConvertedSteps(); - validateStepsPayload(steps); - executeStepsWithRollback(db, steps, clientSession); - } else { - List operations = getConvertedApplyOperations(); - validatePayload(operations, changeId + ".apply"); - executeOperations(db, operations, clientSession); + if (hasStepsPayload()) { + validateStepsPayload(stepsPayload); + executeStepsWithRollback(db, stepsPayload, clientSession); + } else if (applyPayload != null) { + validatePayload(applyPayload, changeId + ".apply"); + applyPayload.getOperator(db).apply(clientSession); } } @@ -232,114 +120,15 @@ public void apply(MongoDatabase db, @Nullable ClientSession clientSession) { public void rollback(MongoDatabase db, @Nullable ClientSession clientSession) { validateSession(clientSession); - if (isStepsFormat()) { + if (hasStepsPayload()) { // Framework-triggered rollback: rollback all steps in reverse order - List steps = getConvertedSteps(); - if (steps != null && !steps.isEmpty()) { - rollbackSteps(db, steps, clientSession); - } - } else { - // Legacy format: execute rollback operations as before - List operations = getConvertedRollbackOperations(); - validatePayload(operations, changeId + ".rollback"); - executeOperations(db, operations, clientSession); - } - } - - /** - * Determines if the payload uses the step-based format. - * - * @return true if using steps format, false for legacy format - */ - private boolean isStepsFormat() { - if (isStepsFormat == null) { - // First check if stepsPayload is directly set (new YAML format with 'steps' key) - if (stepsPayload != null && !stepsPayload.isEmpty()) { - isStepsFormat = true; - return isStepsFormat; - } - - // Fall back to checking applyPayload for step-format data (backward compatibility) - Object rawPayload = getRawPayload("applyPayload"); - isStepsFormat = detectStepsFormat(rawPayload); - } - return isStepsFormat; - } - - @SuppressWarnings("unchecked") - private boolean detectStepsFormat(Object rawPayload) { - if (rawPayload == null) { - return false; - } - - // Check if it's a list where first element has "apply" key (steps format) - if (rawPayload instanceof List && !((List) rawPayload).isEmpty()) { - Object firstElement = ((List) rawPayload).get(0); - if (firstElement instanceof Map) { - Map map = (Map) firstElement; - return map.containsKey("apply"); - } - // Check if it's already a list of MongoStep - if (firstElement instanceof MongoStep) { - return true; - } - } - return false; - } - - private List getConvertedSteps() { - if (convertedSteps == null) { - // First try to get from stepsPayload (new format with 'steps' key) if (stepsPayload != null && !stepsPayload.isEmpty()) { - convertedSteps = stepsPayload; - } else { - // Fall back to applyPayload (backward compatibility for step-format in apply) - convertedSteps = convertToSteps(getRawPayload("applyPayload")); - } - } - return convertedSteps; - } - - private List getConvertedApplyOperations() { - if (convertedApplyOps == null) { - convertedApplyOps = convertRawPayload(getRawPayload("applyPayload")); - } - return convertedApplyOps; - } - - private List getConvertedRollbackOperations() { - if (convertedRollbackOps == null) { - convertedRollbackOps = convertRawPayload(getRawPayload("rollbackPayload")); - } - return convertedRollbackOps; - } - - /** - * 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(); + rollbackAllSteps(db, stepsPayload, clientSession); } + } else if (rollbackPayload != null) { + validatePayload(rollbackPayload, changeId + ".rollback"); + rollbackPayload.getOperator(db).apply(clientSession); } - return null; } private void validateSession(ClientSession clientSession) { @@ -349,23 +138,18 @@ private void validateSession(ClientSession clientSession) { } } - private void validatePayload(List operations, String entityId) { - if (operations == null || operations.isEmpty()) { + private void validatePayload(MongoOperation operation, String entityId) { + if (operation == null) { 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)); - } - + List errors = MongoOperationValidator.validate(operation, entityId); if (!errors.isEmpty()) { throw new MongoTemplateValidationException(errors); } } - private void validateStepsPayload(List steps) { + private void validateStepsPayload(List> steps) { if (steps == null || steps.isEmpty()) { return; } @@ -376,16 +160,6 @@ private void validateStepsPayload(List steps) { } } - private void executeOperations(MongoDatabase db, List operations, ClientSession clientSession) { - if (operations == null || operations.isEmpty()) { - return; - } - - for (MongoOperation op : operations) { - op.getOperator(db).apply(clientSession); - } - } - /** * Executes steps with automatic rollback on failure. * @@ -397,15 +171,17 @@ private void executeOperations(MongoDatabase db, List operations * @param clientSession the client session (may be null) * @throws MongoStepExecutionException if a step fails */ - private void executeStepsWithRollback(MongoDatabase db, List steps, ClientSession clientSession) { + private void executeStepsWithRollback(MongoDatabase db, + List> steps, + ClientSession clientSession) { if (steps == null || steps.isEmpty()) { return; } - List completedSteps = new ArrayList<>(); + List> completedSteps = new ArrayList<>(); for (int i = 0; i < steps.size(); i++) { - MongoStep step = steps.get(i); + TemplateStep step = steps.get(i); int stepNumber = i + 1; // 1-based indexing for user-friendly messages try { @@ -418,7 +194,7 @@ private void executeStepsWithRollback(MongoDatabase db, List steps, C // Rollback completed steps in reverse order if (!completedSteps.isEmpty()) { log.info("Rolling back {} completed step(s)", completedSteps.size()); - rollbackSteps(db, completedSteps, clientSession); + rollbackAllSteps(db, completedSteps, clientSession); } throw new MongoStepExecutionException( @@ -432,7 +208,7 @@ private void executeStepsWithRollback(MongoDatabase db, List steps, C } /** - * Rolls back steps in reverse order. + * Rolls back all steps in reverse order. * *

Steps without rollback operations are skipped. Rollback errors are logged * but don't stop the rollback process for remaining steps.

@@ -441,18 +217,20 @@ private void executeStepsWithRollback(MongoDatabase db, List steps, C * @param steps the list of steps to rollback * @param clientSession the client session (may be null) */ - private void rollbackSteps(MongoDatabase db, List steps, ClientSession clientSession) { + private void rollbackAllSteps(MongoDatabase db, + List> steps, + ClientSession clientSession) { if (steps == null || steps.isEmpty()) { return; } List rollbackErrors = new ArrayList<>(); - List reversedSteps = new ArrayList<>(steps); + List> reversedSteps = new ArrayList<>(steps); Collections.reverse(reversedSteps); for (int i = 0; i < reversedSteps.size(); i++) { - MongoStep step = reversedSteps.get(i); - int originalStepNumber = steps.size() - i; // Original 1-based step number + TemplateStep step = reversedSteps.get(i); + int originalStepNumber = steps.size() - i; if (step.hasRollback()) { try { @@ -471,127 +249,4 @@ private void rollbackSteps(MongoDatabase db, List steps, ClientSessio log.warn("{} rollback operation(s) failed", rollbackErrors.size()); } } - - @SuppressWarnings("unchecked") - private List convertToSteps(Object rawPayload) { - if (rawPayload == null) { - return new ArrayList<>(); - } - - // Handle if it's already a list of MongoStep - if (rawPayload instanceof List && !((List) rawPayload).isEmpty()) { - Object firstElement = ((List) rawPayload).get(0); - if (firstElement instanceof MongoStep) { - return (List) rawPayload; - } - } - - // Handle direct List of step maps - if (rawPayload instanceof List) { - List list = (List) rawPayload; - if (!list.isEmpty()) { - Object firstElement = list.get(0); - if (firstElement instanceof Map && ((Map) firstElement).containsKey("apply")) { - return convertListToSteps(list); - } - } - } - - return new ArrayList<>(); - } - - @SuppressWarnings("unchecked") - private List convertListToSteps(List stepsList) { - List steps = new ArrayList<>(); - - for (Object item : stepsList) { - if (item instanceof Map) { - Map stepMap = (Map) item; - MongoStep step = new MongoStep(); - - Object applyValue = stepMap.get("apply"); - if (applyValue instanceof Map) { - step.setApply(convertMapToOperation((Map) applyValue)); - } - - Object rollbackValue = stepMap.get("rollback"); - if (rollbackValue instanceof Map) { - step.setRollback(convertMapToOperation((Map) rollbackValue)); - } - - steps.add(step); - } - } - - return steps; - } - - @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/exception/MongoStepExecutionException.java b/src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java index 3214437..f2ea5aa 100644 --- a/src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java +++ b/src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java @@ -15,7 +15,8 @@ */ package io.flamingock.template.mongodb.exception; -import io.flamingock.template.mongodb.model.MongoStep; +import io.flamingock.api.template.TemplateStep; +import io.flamingock.template.mongodb.model.MongoOperation; import java.util.Collections; import java.util.List; @@ -47,7 +48,7 @@ public class MongoStepExecutionException extends RuntimeException { private final int stepNumber; - private final List completedSteps; + private final List> completedSteps; /** * Creates a new step execution exception. @@ -57,7 +58,7 @@ public class MongoStepExecutionException extends RuntimeException { * @param completedSteps the list of steps that completed before the failure * @param cause the original exception */ - public MongoStepExecutionException(String message, int stepNumber, List completedSteps, Throwable cause) { + public MongoStepExecutionException(String message, int stepNumber, List> completedSteps, Throwable cause) { super(formatMessage(message, stepNumber), cause); this.stepNumber = stepNumber; this.completedSteps = completedSteps != null @@ -72,7 +73,7 @@ public MongoStepExecutionException(String message, int stepNumber, List completedSteps) { + public MongoStepExecutionException(String message, int stepNumber, List> completedSteps) { this(message, stepNumber, completedSteps, null); } @@ -90,7 +91,7 @@ public int getStepNumber() { * * @return unmodifiable list of completed steps */ - public List getCompletedSteps() { + public List> getCompletedSteps() { return completedSteps; } diff --git a/src/main/java/io/flamingock/template/mongodb/model/MongoStep.java b/src/main/java/io/flamingock/template/mongodb/model/MongoStep.java deleted file mode 100644 index c53b4e4..0000000 --- a/src/main/java/io/flamingock/template/mongodb/model/MongoStep.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright 2025 Flamingock (https://www.flamingock.io) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.flamingock.template.mongodb.model; - -import io.flamingock.api.NonLockGuardedType; -import io.flamingock.api.annotations.NonLockGuarded; - -/** - * Represents a single step in a step-based MongoDB change template. - * - *

Each step contains an {@code apply} operation that executes during the forward - * migration, and an optional {@code rollback} operation that executes if the step - * or a subsequent step fails.

- * - *

YAML Structure

- *
{@code
- * steps:
- *   - apply:
- *       type: createCollection
- *       collection: users
- *     rollback:
- *       type: dropCollection
- *       collection: users
- *   - apply:
- *       type: insert
- *       collection: users
- *       parameters:
- *         documents:
- *           - name: "John"
- *     rollback:
- *       type: delete
- *       collection: users
- *       parameters:
- *         filter: {}
- * }
- * - *

Rollback Behavior

- *
    - *
  • Rollback is optional - steps without rollback are skipped during rollback
  • - *
  • When a step fails, all previously successful steps are rolled back in reverse order
  • - *
  • Rollback errors are logged but don't stop the rollback process
  • - *
- * - * @see MongoOperation - */ -@NonLockGuarded(NonLockGuardedType.NONE) -public class MongoStep { - - private MongoOperation apply; - private MongoOperation rollback; - - public MongoStep() { - } - - public MongoStep(MongoOperation apply, MongoOperation rollback) { - this.apply = apply; - this.rollback = rollback; - } - - /** - * Returns the apply operation for this step. - * - * @return the apply operation (required) - */ - public MongoOperation getApply() { - return apply; - } - - /** - * Sets the apply operation for this step. - * - * @param apply the apply operation - */ - public void setApply(MongoOperation apply) { - this.apply = apply; - } - - /** - * Returns the rollback operation for this step. - * - * @return the rollback operation, or null if no rollback is defined - */ - public MongoOperation getRollback() { - return rollback; - } - - /** - * Sets the rollback operation for this step. - * - * @param rollback the rollback operation (optional) - */ - public void setRollback(MongoOperation rollback) { - this.rollback = rollback; - } - - /** - * Checks if this step has a rollback operation defined. - * - * @return true if a rollback operation is defined - */ - public boolean hasRollback() { - return rollback != null; - } - - @Override - public String toString() { - StringBuilder sb = new StringBuilder("MongoStep{"); - sb.append("apply=").append(apply); - if (rollback != null) { - sb.append(", rollback=").append(rollback); - } - sb.append('}'); - return sb.toString(); - } -} diff --git a/src/main/java/io/flamingock/template/mongodb/model/MongoStepsPayload.java b/src/main/java/io/flamingock/template/mongodb/model/MongoStepsPayload.java deleted file mode 100644 index 7baac5e..0000000 --- a/src/main/java/io/flamingock/template/mongodb/model/MongoStepsPayload.java +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright 2025 Flamingock (https://www.flamingock.io) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.flamingock.template.mongodb.model; - -import io.flamingock.api.NonLockGuardedType; -import io.flamingock.api.annotations.NonLockGuarded; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -/** - * Wrapper for a list of MongoDB steps in the step-based change template format. - * - *

This payload represents the new step-based YAML structure where each step - * contains an apply operation and an optional rollback operation.

- * - *

YAML Structure

- *
{@code
- * id: my-change
- * template: MongoChangeTemplate
- * targetSystem:
- *   id: mongodb
- * steps:
- *   - apply:
- *       type: createCollection
- *       collection: users
- *     rollback:
- *       type: dropCollection
- *       collection: users
- * }
- * - * @see MongoStep - */ -@NonLockGuarded(NonLockGuardedType.NONE) -public class MongoStepsPayload { - - private List steps; - - public MongoStepsPayload() { - this.steps = new ArrayList<>(); - } - - public MongoStepsPayload(List steps) { - this.steps = steps != null ? new ArrayList<>(steps) : new ArrayList<>(); - } - - /** - * Returns the list of steps in this payload. - * - * @return the list of steps (never null) - */ - public List getSteps() { - return steps; - } - - /** - * Sets the list of steps for this payload. - * - * @param steps the list of steps - */ - public void setSteps(List steps) { - this.steps = steps != null ? steps : new ArrayList<>(); - } - - /** - * Returns the number of steps in this payload. - * - * @return the number of steps - */ - public int size() { - return steps.size(); - } - - /** - * Checks if this payload has no steps. - * - * @return true if there are no steps - */ - public boolean isEmpty() { - return steps.isEmpty(); - } - - /** - * Returns an unmodifiable view of the steps list. - * - * @return unmodifiable list of steps - */ - public List getStepsUnmodifiable() { - return Collections.unmodifiableList(steps); - } - - @Override - public String toString() { - return "MongoStepsPayload{" + - "steps=" + steps + - '}'; - } -} 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 efecff0..7ad4ab3 100644 --- a/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java +++ b/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java @@ -15,9 +15,9 @@ */ package io.flamingock.template.mongodb.validation; +import io.flamingock.api.template.TemplateStep; import io.flamingock.template.mongodb.model.MongoOperation; import io.flamingock.template.mongodb.model.MongoOperationType; -import io.flamingock.template.mongodb.model.MongoStep; import java.util.ArrayList; import java.util.List; @@ -429,17 +429,17 @@ private static List validateCreateView(MongoOperation op, Strin * @param entityId the identifier for error reporting (e.g., "changeId.steps[0]") * @return list of validation errors (empty if valid) */ - public static List validateStep(MongoStep step, String entityId) { + public static List validateStep(TemplateStep step, String entityId) { List errors = new ArrayList<>(); if (step == null) { - errors.add(new ValidationError(entityId, "MongoStep", "Step cannot be null")); + errors.add(new ValidationError(entityId, "TemplateStep", "Step cannot be null")); return errors; } // Validate apply operation (required) if (step.getApply() == null) { - errors.add(new ValidationError(entityId, "MongoStep", "Step requires an 'apply' operation")); + errors.add(new ValidationError(entityId, "TemplateStep", "Step requires an 'apply' operation")); } else { errors.addAll(validate(step.getApply(), entityId + ".apply")); } @@ -459,16 +459,16 @@ public static List validateStep(MongoStep step, String entityId * @param entityId the base identifier for error reporting (e.g., "changeId") * @return list of validation errors (empty if all steps are valid) */ - public static List validateSteps(List steps, String entityId) { + public static List validateSteps(List> steps, String entityId) { List errors = new ArrayList<>(); if (steps == null) { - errors.add(new ValidationError(entityId, "MongoSteps", "Steps list cannot be null")); + errors.add(new ValidationError(entityId, "TemplateSteps", "Steps list cannot be null")); return errors; } if (steps.isEmpty()) { - errors.add(new ValidationError(entityId, "MongoSteps", "Steps list cannot be empty")); + errors.add(new ValidationError(entityId, "TemplateSteps", "Steps list cannot be empty")); return errors; } diff --git a/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java b/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java index 79ccea2..dc69fc1 100644 --- a/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java +++ b/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java @@ -21,6 +21,7 @@ import com.mongodb.client.MongoClients; import com.mongodb.client.MongoDatabase; import io.flamingock.api.annotations.EnableFlamingock; +import io.flamingock.api.template.TemplateStep; import io.flamingock.store.mongodb.sync.MongoDBSyncAuditStore; import io.flamingock.internal.common.core.audit.AuditEntry; import io.flamingock.internal.core.builder.FlamingockFactory; @@ -190,8 +191,8 @@ void happyPath() { } @Test - @DisplayName("WHEN rollback is invoked THEN multiple rollback operations execute in defined order") - void rollbackWithMultipleOperations() { + @DisplayName("WHEN rollback is invoked 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( @@ -208,37 +209,15 @@ void rollbackWithMultipleOperations() { "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); @@ -254,31 +233,29 @@ private boolean collectionExists(String collectionName) { @Test @DisplayName("WHEN step-based apply succeeds THEN all steps are executed") - void stepBasedApplySuccess() throws Exception { + void stepBasedApplySuccess() { MongoChangeTemplate template = new MongoChangeTemplate(); template.setChangeId("step-test"); template.setTransactional(false); - // Create step payload programmatically - List> steps = new ArrayList<>(); + // Create step payload using TemplateStep + List> steps = new ArrayList<>(); // Step 1: Create collection - Map step1 = new HashMap<>(); - Map step1Apply = new HashMap<>(); - step1Apply.put("type", "createCollection"); - step1Apply.put("collection", "stepRollbackTest"); - Map step1Rollback = new HashMap<>(); - step1Rollback.put("type", "dropCollection"); - step1Rollback.put("collection", "stepRollbackTest"); - step1.put("apply", step1Apply); - step1.put("rollback", step1Rollback); - steps.add(step1); + MongoOperation step1Apply = new MongoOperation(); + step1Apply.setType("createCollection"); + step1Apply.setCollection("stepRollbackTest"); + + MongoOperation step1Rollback = new MongoOperation(); + step1Rollback.setType("dropCollection"); + step1Rollback.setCollection("stepRollbackTest"); + + steps.add(new TemplateStep<>(step1Apply, step1Rollback)); // Step 2: Insert documents - Map step2 = new HashMap<>(); - Map step2Apply = new HashMap<>(); - step2Apply.put("type", "insert"); - step2Apply.put("collection", "stepRollbackTest"); + MongoOperation step2Apply = new MongoOperation(); + step2Apply.setType("insert"); + step2Apply.setCollection("stepRollbackTest"); Map step2Params = new HashMap<>(); List> docs = new ArrayList<>(); Map doc1 = new HashMap<>(); @@ -286,18 +263,18 @@ void stepBasedApplySuccess() throws Exception { doc1.put("value", 100); docs.add(doc1); step2Params.put("documents", docs); - step2Apply.put("parameters", step2Params); - Map step2Rollback = new HashMap<>(); - step2Rollback.put("type", "delete"); - step2Rollback.put("collection", "stepRollbackTest"); + step2Apply.setParameters(step2Params); + + MongoOperation step2Rollback = new MongoOperation(); + step2Rollback.setType("delete"); + step2Rollback.setCollection("stepRollbackTest"); Map step2RollbackParams = new HashMap<>(); step2RollbackParams.put("filter", new HashMap<>()); - step2Rollback.put("parameters", step2RollbackParams); - step2.put("apply", step2Apply); - step2.put("rollback", step2Rollback); - steps.add(step2); + step2Rollback.setParameters(step2RollbackParams); + + steps.add(new TemplateStep<>(step2Apply, step2Rollback)); - setRawApplyPayload(template, steps); + template.setStepsPayload(steps); template.apply(mongoDatabase, null); @@ -308,38 +285,36 @@ void stepBasedApplySuccess() throws Exception { @Test @DisplayName("WHEN step 2 fails THEN step 1 is rolled back") - void stepBasedRollbackOnFailure() throws Exception { + void stepBasedRollbackOnFailure() { MongoChangeTemplate template = new MongoChangeTemplate(); template.setChangeId("step-rollback-test"); template.setTransactional(false); // Create step payload that will fail on step 2 - List> steps = new ArrayList<>(); + List> steps = new ArrayList<>(); // Step 1: Create collection (will succeed) - Map step1 = new HashMap<>(); - Map step1Apply = new HashMap<>(); - step1Apply.put("type", "createCollection"); - step1Apply.put("collection", "stepRollbackTest"); - Map step1Rollback = new HashMap<>(); - step1Rollback.put("type", "dropCollection"); - step1Rollback.put("collection", "stepRollbackTest"); - step1.put("apply", step1Apply); - step1.put("rollback", step1Rollback); - steps.add(step1); + MongoOperation step1Apply = new MongoOperation(); + step1Apply.setType("createCollection"); + step1Apply.setCollection("stepRollbackTest"); + + MongoOperation step1Rollback = new MongoOperation(); + step1Rollback.setType("dropCollection"); + step1Rollback.setCollection("stepRollbackTest"); + + steps.add(new TemplateStep<>(step1Apply, step1Rollback)); // Step 2: Try to create a collection that already exists (will fail) // First create the collection to cause conflict mongoDatabase.createCollection("conflictCollection"); - Map step2 = new HashMap<>(); - Map step2Apply = new HashMap<>(); - step2Apply.put("type", "createCollection"); - step2Apply.put("collection", "conflictCollection"); // Already exists, will fail - step2.put("apply", step2Apply); - steps.add(step2); + MongoOperation step2Apply = new MongoOperation(); + step2Apply.setType("createCollection"); + step2Apply.setCollection("conflictCollection"); // Already exists, will fail + + steps.add(new TemplateStep<>(step2Apply, null)); - setRawApplyPayload(template, steps); + template.setStepsPayload(steps); MongoStepExecutionException exception = assertThrows(MongoStepExecutionException.class, () -> template.apply(mongoDatabase, null)); @@ -357,7 +332,7 @@ void stepBasedRollbackOnFailure() throws Exception { @Test @DisplayName("WHEN step has no rollback THEN it is skipped during rollback") - void stepWithNoRollbackIsSkipped() throws Exception { + void stepWithNoRollbackIsSkipped() { // First create the collection and insert data mongoDatabase.createCollection("stepRollbackTest"); mongoDatabase.getCollection("stepRollbackTest").insertOne(new Document("name", "Existing")); @@ -367,35 +342,33 @@ void stepWithNoRollbackIsSkipped() throws Exception { template.setTransactional(false); // Create step payload where step 1 has no rollback - List> steps = new ArrayList<>(); + List> steps = new ArrayList<>(); // Step 1: Insert (no rollback defined) - Map step1 = new HashMap<>(); - Map step1Apply = new HashMap<>(); - step1Apply.put("type", "insert"); - step1Apply.put("collection", "stepRollbackTest"); + MongoOperation step1Apply = new MongoOperation(); + step1Apply.setType("insert"); + step1Apply.setCollection("stepRollbackTest"); Map step1Params = new HashMap<>(); List> docs = new ArrayList<>(); Map doc1 = new HashMap<>(); doc1.put("name", "NoRollback"); docs.add(doc1); step1Params.put("documents", docs); - step1Apply.put("parameters", step1Params); - step1.put("apply", step1Apply); + step1Apply.setParameters(step1Params); + // Note: no rollback defined for step 1 - steps.add(step1); + steps.add(new TemplateStep<>(step1Apply, null)); // Step 2: Create collection that already exists (will fail) mongoDatabase.createCollection("conflictCollection2"); - Map step2 = new HashMap<>(); - Map step2Apply = new HashMap<>(); - step2Apply.put("type", "createCollection"); - step2Apply.put("collection", "conflictCollection2"); // Already exists, will fail - step2.put("apply", step2Apply); - steps.add(step2); + MongoOperation step2Apply = new MongoOperation(); + step2Apply.setType("createCollection"); + step2Apply.setCollection("conflictCollection2"); // Already exists, will fail - setRawApplyPayload(template, steps); + steps.add(new TemplateStep<>(step2Apply, null)); + + template.setStepsPayload(steps); assertThrows(MongoStepExecutionException.class, () -> template.apply(mongoDatabase, null)); @@ -412,7 +385,7 @@ void stepWithNoRollbackIsSkipped() throws Exception { @Test @DisplayName("WHEN framework triggers rollback for step-based change THEN all steps are rolled back") - void frameworkTriggeredRollbackForSteps() throws Exception { + void frameworkTriggeredRollbackForSteps() { // Set up the state as if steps had been applied mongoDatabase.createCollection("stepRollbackTest"); mongoDatabase.getCollection("stepRollbackTest").insertMany(Arrays.asList( @@ -429,62 +402,63 @@ void frameworkTriggeredRollbackForSteps() throws Exception { template.setTransactional(false); // Create step payload - List> steps = new ArrayList<>(); + List> steps = new ArrayList<>(); // Step 1: Create collection - Map step1 = new HashMap<>(); - Map step1Apply = new HashMap<>(); - step1Apply.put("type", "createCollection"); - step1Apply.put("collection", "stepRollbackTest"); - Map step1Rollback = new HashMap<>(); - step1Rollback.put("type", "dropCollection"); - step1Rollback.put("collection", "stepRollbackTest"); - step1.put("apply", step1Apply); - step1.put("rollback", step1Rollback); - steps.add(step1); + MongoOperation step1Apply = new MongoOperation(); + step1Apply.setType("createCollection"); + step1Apply.setCollection("stepRollbackTest"); + + MongoOperation step1Rollback = new MongoOperation(); + step1Rollback.setType("dropCollection"); + step1Rollback.setCollection("stepRollbackTest"); + + steps.add(new TemplateStep<>(step1Apply, step1Rollback)); // Step 2: Insert documents - Map step2 = new HashMap<>(); - Map step2Apply = new HashMap<>(); - step2Apply.put("type", "insert"); - step2Apply.put("collection", "stepRollbackTest"); + MongoOperation step2Apply = new MongoOperation(); + step2Apply.setType("insert"); + step2Apply.setCollection("stepRollbackTest"); Map step2Params = new HashMap<>(); List> docs = new ArrayList<>(); - docs.add(new HashMap() {{ put("name", "Item1"); }}); + Map doc1 = new HashMap<>(); + doc1.put("name", "Item1"); + docs.add(doc1); step2Params.put("documents", docs); - step2Apply.put("parameters", step2Params); - Map step2Rollback = new HashMap<>(); - step2Rollback.put("type", "delete"); - step2Rollback.put("collection", "stepRollbackTest"); + step2Apply.setParameters(step2Params); + + MongoOperation step2Rollback = new MongoOperation(); + step2Rollback.setType("delete"); + step2Rollback.setCollection("stepRollbackTest"); Map step2RollbackParams = new HashMap<>(); step2RollbackParams.put("filter", new HashMap<>()); - step2Rollback.put("parameters", step2RollbackParams); - step2.put("apply", step2Apply); - step2.put("rollback", step2Rollback); - steps.add(step2); + step2Rollback.setParameters(step2RollbackParams); + + steps.add(new TemplateStep<>(step2Apply, step2Rollback)); // Step 3: Create index - Map step3 = new HashMap<>(); - Map step3Apply = new HashMap<>(); - step3Apply.put("type", "createIndex"); - step3Apply.put("collection", "stepRollbackTest"); + MongoOperation step3Apply = new MongoOperation(); + step3Apply.setType("createIndex"); + step3Apply.setCollection("stepRollbackTest"); Map step3Params = new HashMap<>(); - step3Params.put("keys", new HashMap() {{ put("name", 1); }}); + Map keys = new HashMap<>(); + keys.put("name", 1); + step3Params.put("keys", keys); Map step3Options = new HashMap<>(); step3Options.put("name", "step_index"); step3Params.put("options", step3Options); - step3Apply.put("parameters", step3Params); - Map step3Rollback = new HashMap<>(); - step3Rollback.put("type", "dropIndex"); - step3Rollback.put("collection", "stepRollbackTest"); + step3Apply.setParameters(step3Params); + + MongoOperation step3Rollback = new MongoOperation(); + step3Rollback.setType("dropIndex"); + step3Rollback.setCollection("stepRollbackTest"); Map step3RollbackParams = new HashMap<>(); step3RollbackParams.put("indexName", "step_index"); - step3Rollback.put("parameters", step3RollbackParams); - step3.put("apply", step3Apply); - step3.put("rollback", step3Rollback); - steps.add(step3); + step3Rollback.setParameters(step3RollbackParams); + + steps.add(new TemplateStep<>(step3Apply, step3Rollback)); - setRawApplyPayload(template, steps); + template.setStepsPayload(steps); assertTrue(collectionExists("stepRollbackTest"), "Collection should exist before rollback"); @@ -494,28 +468,4 @@ void frameworkTriggeredRollbackForSteps() throws Exception { assertFalse(collectionExists("stepRollbackTest"), "Collection should not exist after framework-triggered rollback"); } - - /** - * Helper method to set raw apply payload via reflection. - * This simulates how the framework sets the payload when loading from YAML. - */ - private void setRawApplyPayload(MongoChangeTemplate template, Object payload) throws Exception { - java.lang.reflect.Field field = findField(template.getClass(), "applyPayload"); - if (field != null) { - field.setAccessible(true); - field.set(template, payload); - } - } - - 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; - } - } 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..e3952de 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 @@ -4,5 +4,5 @@ template: MongoChangeTemplate targetSystem: id: "mongodb" apply: - - type: createCollection - collection: users + type: createCollection + 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..13ffcff 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 @@ -4,13 +4,13 @@ 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" ] + type: insert + collection: users + parameters: + documents: + - name: "Admin" + email: "admin@company.com" + roles: [ "superuser" ] + - name: "Backup" + email: "backup@company.com" + roles: [ "readonly" ] 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" From 21df59c50b623e6f2427fb440dcda90551d1a813 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Sun, 8 Feb 2026 19:43:00 +0000 Subject: [PATCH 3/8] refactor: AbstractSteppableTemplate --- build.gradle.kts | 33 +++++---- .../template/mongodb/MongoChangeTemplate.java | 67 +++++-------------- .../mongodb/MongoChangeTemplateTest.java | 21 ++++-- .../_0001__create_users_collections.yaml | 10 ++- .../mongodb/changes/_0002__seed_users.yaml | 28 +++++--- 5 files changed, 75 insertions(+), 84 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 598d1b3..d795daf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,24 +8,33 @@ plugins { } fun flamingockVersion(): String { - var passedAsParameter = false + var source = "default" val flamingockVersionAsParameter: String? = project.findProperty("flamingockVersion")?.toString() val flamingockVersion: String = if (flamingockVersionAsParameter != null) { - passedAsParameter = true + source = "parameter" 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) + // Default to development version for local builds + // Use -PflamingockVersion=X.Y.Z to override, or set to fetch from Maven Central + val useLatestFromCentral = project.findProperty("useLatestFromCentral")?.toString()?.toBoolean() ?: false + if (useLatestFromCentral) { + source = "maven-central" + 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) + } + } else { + // Default local development version + "1.0.1" } } - logger.lifecycle("Building with flamingock version${if (passedAsParameter) "[from parameter]" else ""}: $flamingockVersion") + logger.lifecycle("Building with flamingock version[$source]: $flamingockVersion") return flamingockVersion } diff --git a/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java index 643e1bf..a111e28 100644 --- a/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java +++ b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java @@ -20,7 +20,7 @@ import io.flamingock.api.annotations.Apply; import io.flamingock.api.annotations.Nullable; import io.flamingock.api.annotations.Rollback; -import io.flamingock.api.template.AbstractChangeTemplate; +import io.flamingock.api.template.AbstractSteppableTemplate; import io.flamingock.api.template.TemplateStep; import io.flamingock.template.mongodb.exception.MongoStepExecutionException; import io.flamingock.template.mongodb.model.MongoOperation; @@ -37,11 +37,11 @@ /** * MongoDB Change Template for executing declarative MongoDB operations defined in YAML. * - *

Supported YAML Structures

+ *

This template extends {@link AbstractSteppableTemplate} and is designed for step-based changes + * where each step can have its own apply and rollback operation. + * + *

YAML Structure

* - *

Step-Based Format (Recommended)

- *

Each step contains an apply operation and optional rollback operation. - * When a step fails, all previously successful steps are rolled back in reverse order.

*
{@code
  * id: create-orders-collection
  * transactional: false
@@ -68,34 +68,17 @@
  *         filter: {}
  * }
* - *

Simple Apply/Rollback Format

- *

Single apply and rollback operation. The framework handles rollback invocation on failure.

- *
{@code
- * id: create-orders-collection
- * transactional: true
- * template: MongoChangeTemplate
- * targetSystem:
- *   id: "mongodb"
- * apply:
- *   type: createCollection
- *   collection: orders
- * rollback:
- *   type: dropCollection
- *   collection: orders
- * }
- * *

Execution Behavior

*
    - *
  • Step-based format: Each step's apply operation executes sequentially. On failure, - * rollback operations for completed steps execute in reverse order.
  • - *
  • Simple format: Apply operation executes. Rollback executes on failure.
  • - *
  • In transactional mode, MongoDB transaction provides atomicity.
  • + *
  • Each step's apply operation executes sequentially
  • + *
  • On failure, rollback operations for completed steps execute in reverse order
  • + *
  • In transactional mode, MongoDB transaction provides atomicity
  • *
* * @see MongoOperation * @see TemplateStep */ -public class MongoChangeTemplate extends AbstractChangeTemplate { +public class MongoChangeTemplate extends AbstractSteppableTemplate { private static final Logger log = LoggerFactory.getLogger(MongoChangeTemplate.class); @@ -107,12 +90,11 @@ public MongoChangeTemplate() { public void apply(MongoDatabase db, @Nullable ClientSession clientSession) { validateSession(clientSession); - if (hasStepsPayload()) { - validateStepsPayload(stepsPayload); - executeStepsWithRollback(db, stepsPayload, clientSession); - } else if (applyPayload != null) { - validatePayload(applyPayload, changeId + ".apply"); - applyPayload.getOperator(db).apply(clientSession); + if (hasSteps()) { + validateStepsPayload(steps); + executeStepsWithRollback(db, steps, clientSession); + } else { + log.warn("No steps defined for change[{}]", changeId); } } @@ -120,14 +102,8 @@ public void apply(MongoDatabase db, @Nullable ClientSession clientSession) { public void rollback(MongoDatabase db, @Nullable ClientSession clientSession) { validateSession(clientSession); - if (hasStepsPayload()) { - // Framework-triggered rollback: rollback all steps in reverse order - if (stepsPayload != null && !stepsPayload.isEmpty()) { - rollbackAllSteps(db, stepsPayload, clientSession); - } - } else if (rollbackPayload != null) { - validatePayload(rollbackPayload, changeId + ".rollback"); - rollbackPayload.getOperator(db).apply(clientSession); + if (hasSteps()) { + rollbackAllSteps(db, steps, clientSession); } } @@ -138,17 +114,6 @@ private void validateSession(ClientSession clientSession) { } } - private void validatePayload(MongoOperation operation, String entityId) { - if (operation == null) { - return; - } - - List errors = MongoOperationValidator.validate(operation, entityId); - if (!errors.isEmpty()) { - throw new MongoTemplateValidationException(errors); - } - } - private void validateStepsPayload(List> steps) { if (steps == null || steps.isEmpty()) { return; diff --git a/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java b/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java index dc69fc1..755cc59 100644 --- a/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java +++ b/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java @@ -192,7 +192,7 @@ void happyPath() { @Test @DisplayName("WHEN rollback is invoked THEN rollback operation executes") - void rollbackWithSingleOperation() { + void rollbackWithSingleStep() { // First, set up the state by creating the collection and inserting data mongoDatabase.createCollection("rollbackTestCollection"); mongoDatabase.getCollection("rollbackTestCollection").insertMany(Arrays.asList( @@ -212,12 +212,19 @@ void rollbackWithSingleOperation() { template.setChangeId("rollback-test"); template.setTransactional(false); - // Set rollback payload - drop collection + // Set rollback step - drop collection + List> steps = new ArrayList<>(); MongoOperation dropCollectionOp = new MongoOperation(); dropCollectionOp.setType("dropCollection"); dropCollectionOp.setCollection("rollbackTestCollection"); - template.setRollbackPayload(dropCollectionOp); + // Create a step with dummy apply and our rollback + MongoOperation dummyApply = new MongoOperation(); + dummyApply.setType("createCollection"); + dummyApply.setCollection("dummyCollection"); + + steps.add(new TemplateStep<>(dummyApply, dropCollectionOp)); + template.setSteps(steps); template.rollback(mongoDatabase, null); @@ -274,7 +281,7 @@ void stepBasedApplySuccess() { steps.add(new TemplateStep<>(step2Apply, step2Rollback)); - template.setStepsPayload(steps); + template.setSteps(steps); template.apply(mongoDatabase, null); @@ -314,7 +321,7 @@ void stepBasedRollbackOnFailure() { steps.add(new TemplateStep<>(step2Apply, null)); - template.setStepsPayload(steps); + template.setSteps(steps); MongoStepExecutionException exception = assertThrows(MongoStepExecutionException.class, () -> template.apply(mongoDatabase, null)); @@ -368,7 +375,7 @@ void stepWithNoRollbackIsSkipped() { steps.add(new TemplateStep<>(step2Apply, null)); - template.setStepsPayload(steps); + template.setSteps(steps); assertThrows(MongoStepExecutionException.class, () -> template.apply(mongoDatabase, null)); @@ -458,7 +465,7 @@ void frameworkTriggeredRollbackForSteps() { steps.add(new TemplateStep<>(step3Apply, step3Rollback)); - template.setStepsPayload(steps); + template.setSteps(steps); assertTrue(collectionExists("stepRollbackTest"), "Collection should exist before 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 e3952de..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 13ffcff..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: {} From bf7967e9b0b3fd2a080add10abb8073146f6a9b1 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Sun, 15 Feb 2026 12:02:25 +0000 Subject: [PATCH 4/8] refactor: adapt to new @ChangeTemplate multistep --- build.gradle.kts | 2 +- .../template/mongodb/MongoChangeTemplate.java | 138 ++-------- .../MongoStepExecutionException.java | 110 -------- .../validation/MongoOperationValidator.java | 62 ----- .../io.flamingock.api.template.ChangeTemplate | 2 +- .../mongodb/MongoChangeTemplateTest.java | 256 +++--------------- 6 files changed, 58 insertions(+), 512 deletions(-) delete mode 100644 src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java diff --git a/build.gradle.kts b/build.gradle.kts index d795daf..4723f16 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,7 +31,7 @@ fun flamingockVersion(): String { } } else { // Default local development version - "1.0.1" + "1.1.0-rc.2" } } logger.lifecycle("Building with flamingock version[$source]: $flamingockVersion") diff --git a/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java index a111e28..97b6229 100644 --- a/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java +++ b/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java @@ -18,11 +18,10 @@ 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.AbstractSteppableTemplate; -import io.flamingock.api.template.TemplateStep; -import io.flamingock.template.mongodb.exception.MongoStepExecutionException; +import io.flamingock.api.template.AbstractChangeTemplate; import io.flamingock.template.mongodb.model.MongoOperation; import io.flamingock.template.mongodb.validation.MongoOperationValidator; import io.flamingock.template.mongodb.validation.MongoTemplateValidationException; @@ -30,15 +29,16 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; /** * MongoDB Change Template for executing declarative MongoDB operations defined in YAML. * - *

This template extends {@link AbstractSteppableTemplate} and is designed for step-based changes - * where each step can have its own apply and rollback operation. + *

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

* @@ -70,15 +70,15 @@ * *

Execution Behavior

*
    - *
  • Each step's apply operation executes sequentially
  • - *
  • On failure, rollback operations for completed steps execute in reverse order
  • + *
  • 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 - * @see TemplateStep */ -public class MongoChangeTemplate extends AbstractSteppableTemplate { +@ChangeTemplate(multiStep = true) +public class MongoChangeTemplate extends AbstractChangeTemplate { private static final Logger log = LoggerFactory.getLogger(MongoChangeTemplate.class); @@ -90,21 +90,18 @@ public MongoChangeTemplate() { public void apply(MongoDatabase db, @Nullable ClientSession clientSession) { validateSession(clientSession); - if (hasSteps()) { - validateStepsPayload(steps); - executeStepsWithRollback(db, steps, clientSession); - } else { - log.warn("No steps defined for change[{}]", changeId); + List errors = MongoOperationValidator.validate(applyPayload, changeId); + if (!errors.isEmpty()) { + throw new MongoTemplateValidationException(errors); } + + applyPayload.getOperator(db).apply(clientSession); } @Rollback public void rollback(MongoDatabase db, @Nullable ClientSession clientSession) { validateSession(clientSession); - - if (hasSteps()) { - rollbackAllSteps(db, steps, clientSession); - } + rollbackPayload.getOperator(db).apply(clientSession); } private void validateSession(ClientSession clientSession) { @@ -113,105 +110,4 @@ private void validateSession(ClientSession clientSession) { String.format("Transactional change[%s] requires transactional ecosystem with ClientSession", changeId)); } } - - private void validateStepsPayload(List> steps) { - if (steps == null || steps.isEmpty()) { - return; - } - - List errors = MongoOperationValidator.validateSteps(steps, changeId); - if (!errors.isEmpty()) { - throw new MongoTemplateValidationException(errors); - } - } - - /** - * Executes steps with automatic rollback on failure. - * - *

When a step fails, all previously successful steps are rolled back - * in reverse order. Rollback errors are logged but don't stop the rollback process.

- * - * @param db the MongoDB database - * @param steps the list of steps to execute - * @param clientSession the client session (may be null) - * @throws MongoStepExecutionException if a step fails - */ - private void executeStepsWithRollback(MongoDatabase db, - List> steps, - ClientSession clientSession) { - if (steps == null || steps.isEmpty()) { - return; - } - - List> completedSteps = new ArrayList<>(); - - for (int i = 0; i < steps.size(); i++) { - TemplateStep step = steps.get(i); - int stepNumber = i + 1; // 1-based indexing for user-friendly messages - - try { - log.debug("Executing step {} apply operation", stepNumber); - step.getApply().getOperator(db).apply(clientSession); - completedSteps.add(step); - } catch (Exception e) { - log.error("Step {} failed: {}", stepNumber, e.getMessage()); - - // Rollback completed steps in reverse order - if (!completedSteps.isEmpty()) { - log.info("Rolling back {} completed step(s)", completedSteps.size()); - rollbackAllSteps(db, completedSteps, clientSession); - } - - throw new MongoStepExecutionException( - e.getMessage(), - stepNumber, - new ArrayList<>(completedSteps), - e - ); - } - } - } - - /** - * Rolls back all steps in reverse order. - * - *

Steps without rollback operations are skipped. Rollback errors are logged - * but don't stop the rollback process for remaining steps.

- * - * @param db the MongoDB database - * @param steps the list of steps to rollback - * @param clientSession the client session (may be null) - */ - private void rollbackAllSteps(MongoDatabase db, - List> steps, - ClientSession clientSession) { - if (steps == null || steps.isEmpty()) { - return; - } - - List rollbackErrors = new ArrayList<>(); - List> reversedSteps = new ArrayList<>(steps); - Collections.reverse(reversedSteps); - - for (int i = 0; i < reversedSteps.size(); i++) { - TemplateStep step = reversedSteps.get(i); - int originalStepNumber = steps.size() - i; - - if (step.hasRollback()) { - try { - log.debug("Executing rollback for step {}", originalStepNumber); - step.getRollback().getOperator(db).apply(clientSession); - } catch (Exception e) { - log.error("Rollback failed for step {}: {}", originalStepNumber, e.getMessage()); - rollbackErrors.add(e); - } - } else { - log.debug("Step {} has no rollback operation, skipping", originalStepNumber); - } - } - - if (!rollbackErrors.isEmpty()) { - log.warn("{} rollback operation(s) failed", rollbackErrors.size()); - } - } } diff --git a/src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java b/src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java deleted file mode 100644 index f2ea5aa..0000000 --- a/src/main/java/io/flamingock/template/mongodb/exception/MongoStepExecutionException.java +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2025 Flamingock (https://www.flamingock.io) - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package io.flamingock.template.mongodb.exception; - -import io.flamingock.api.template.TemplateStep; -import io.flamingock.template.mongodb.model.MongoOperation; - -import java.util.Collections; -import java.util.List; - -/** - * Exception thrown when a step execution fails during apply or rollback. - * - *

This exception provides context about which step failed (using 1-based indexing - * for user-friendly error messages) and which steps were successfully completed - * before the failure.

- * - *

Error Information

- *
    - *
  • stepNumber - The 1-based index of the failed step
  • - *
  • completedSteps - List of steps that completed successfully before the failure
  • - *
  • cause - The original exception that caused the failure
  • - *
- * - *

Example

- *
{@code
- * try {
- *     executeSteps(steps);
- * } catch (MongoStepExecutionException e) {
- *     System.out.println("Step " + e.getStepNumber() + " failed: " + e.getMessage());
- *     System.out.println("Completed " + e.getCompletedSteps().size() + " steps before failure");
- * }
- * }
- */ -public class MongoStepExecutionException extends RuntimeException { - - private final int stepNumber; - private final List> completedSteps; - - /** - * Creates a new step execution exception. - * - * @param message the error message - * @param stepNumber the 1-based index of the failed step - * @param completedSteps the list of steps that completed before the failure - * @param cause the original exception - */ - public MongoStepExecutionException(String message, int stepNumber, List> completedSteps, Throwable cause) { - super(formatMessage(message, stepNumber), cause); - this.stepNumber = stepNumber; - this.completedSteps = completedSteps != null - ? Collections.unmodifiableList(completedSteps) - : Collections.emptyList(); - } - - /** - * Creates a new step execution exception without a cause. - * - * @param message the error message - * @param stepNumber the 1-based index of the failed step - * @param completedSteps the list of steps that completed before the failure - */ - public MongoStepExecutionException(String message, int stepNumber, List> completedSteps) { - this(message, stepNumber, completedSteps, null); - } - - /** - * Returns the 1-based index of the step that failed. - * - * @return the step number (1-based) - */ - public int getStepNumber() { - return stepNumber; - } - - /** - * Returns the list of steps that completed successfully before the failure. - * - * @return unmodifiable list of completed steps - */ - public List> getCompletedSteps() { - return completedSteps; - } - - /** - * Returns the number of steps that completed before the failure. - * - * @return the count of completed steps - */ - public int getCompletedStepCount() { - return completedSteps.size(); - } - - private static String formatMessage(String message, int stepNumber) { - return String.format("Step %d failed: %s", stepNumber, message); - } -} 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 7ad4ab3..81599d5 100644 --- a/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java +++ b/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java @@ -15,7 +15,6 @@ */ package io.flamingock.template.mongodb.validation; -import io.flamingock.api.template.TemplateStep; import io.flamingock.template.mongodb.model.MongoOperation; import io.flamingock.template.mongodb.model.MongoOperationType; @@ -419,65 +418,4 @@ private static List validateCreateView(MongoOperation op, Strin return errors; } - /** - * Validates a single MongoDB step. - * - *

Validates both the apply operation (required) and the rollback operation (optional). - * If rollback is present, it must be valid.

- * - * @param step the step to validate - * @param entityId the identifier for error reporting (e.g., "changeId.steps[0]") - * @return list of validation errors (empty if valid) - */ - public static List validateStep(TemplateStep step, String entityId) { - List errors = new ArrayList<>(); - - if (step == null) { - errors.add(new ValidationError(entityId, "TemplateStep", "Step cannot be null")); - return errors; - } - - // Validate apply operation (required) - if (step.getApply() == null) { - errors.add(new ValidationError(entityId, "TemplateStep", "Step requires an 'apply' operation")); - } else { - errors.addAll(validate(step.getApply(), entityId + ".apply")); - } - - // Validate rollback operation (optional, but if present must be valid) - if (step.getRollback() != null) { - errors.addAll(validate(step.getRollback(), entityId + ".rollback")); - } - - return errors; - } - - /** - * Validates a list of MongoDB steps. - * - * @param steps the list of steps to validate - * @param entityId the base identifier for error reporting (e.g., "changeId") - * @return list of validation errors (empty if all steps are valid) - */ - public static List validateSteps(List> steps, String entityId) { - List errors = new ArrayList<>(); - - if (steps == null) { - errors.add(new ValidationError(entityId, "TemplateSteps", "Steps list cannot be null")); - return errors; - } - - if (steps.isEmpty()) { - errors.add(new ValidationError(entityId, "TemplateSteps", "Steps list cannot be empty")); - return errors; - } - - for (int i = 0; i < steps.size(); i++) { - // Use 1-based indexing for user-friendly error messages - String stepEntityId = entityId + ".steps[" + (i + 1) + "]"; - errors.addAll(validateStep(steps.get(i), stepEntityId)); - } - - 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 755cc59..3bd39af 100644 --- a/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java +++ b/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java @@ -21,12 +21,10 @@ import com.mongodb.client.MongoClients; import com.mongodb.client.MongoDatabase; import io.flamingock.api.annotations.EnableFlamingock; -import io.flamingock.api.template.TemplateStep; import io.flamingock.store.mongodb.sync.MongoDBSyncAuditStore; import io.flamingock.internal.common.core.audit.AuditEntry; import io.flamingock.internal.core.builder.FlamingockFactory; import io.flamingock.targetsystem.mongodb.sync.MongoDBSyncTargetSystem; -import io.flamingock.template.mongodb.exception.MongoStepExecutionException; import io.flamingock.template.mongodb.model.MongoOperation; import org.bson.Document; @@ -48,10 +46,9 @@ import static io.flamingock.internal.util.constants.CommunityPersistenceConstants.DEFAULT_AUDIT_STORE_NAME; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -@EnableFlamingock(configFile = "flamingock/pipeline.yaml") +@EnableFlamingock(configFile = "flamingock/pipeline.yaml", strictTemplateValidation = false) @Testcontainers class MongoChangeTemplateTest { @@ -191,18 +188,14 @@ void happyPath() { } @Test - @DisplayName("WHEN rollback is invoked THEN rollback operation executes") - void rollbackWithSingleStep() { + @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(), @@ -212,19 +205,11 @@ void rollbackWithSingleStep() { template.setChangeId("rollback-test"); template.setTransactional(false); - // Set rollback step - drop collection - List> steps = new ArrayList<>(); + // Set rollback payload - drop collection MongoOperation dropCollectionOp = new MongoOperation(); dropCollectionOp.setType("dropCollection"); dropCollectionOp.setCollection("rollbackTestCollection"); - - // Create a step with dummy apply and our rollback - MongoOperation dummyApply = new MongoOperation(); - dummyApply.setType("createCollection"); - dummyApply.setCollection("dummyCollection"); - - steps.add(new TemplateStep<>(dummyApply, dropCollectionOp)); - template.setSteps(steps); + template.setRollbackPayload(dropCollectionOp); template.rollback(mongoDatabase, null); @@ -239,239 +224,76 @@ private boolean collectionExists(String collectionName) { } @Test - @DisplayName("WHEN step-based apply succeeds THEN all steps are executed") - void stepBasedApplySuccess() { + @DisplayName("WHEN apply is invoked with a single operation THEN operation executes successfully") + void singleOperationApplySuccess() { MongoChangeTemplate template = new MongoChangeTemplate(); - template.setChangeId("step-test"); + template.setChangeId("apply-test"); template.setTransactional(false); - // Create step payload using TemplateStep - List> steps = new ArrayList<>(); - - // Step 1: Create collection - MongoOperation step1Apply = new MongoOperation(); - step1Apply.setType("createCollection"); - step1Apply.setCollection("stepRollbackTest"); - - MongoOperation step1Rollback = new MongoOperation(); - step1Rollback.setType("dropCollection"); - step1Rollback.setCollection("stepRollbackTest"); - - steps.add(new TemplateStep<>(step1Apply, step1Rollback)); - - // Step 2: Insert documents - MongoOperation step2Apply = new MongoOperation(); - step2Apply.setType("insert"); - step2Apply.setCollection("stepRollbackTest"); - Map step2Params = new HashMap<>(); - List> docs = new ArrayList<>(); - Map doc1 = new HashMap<>(); - doc1.put("name", "Test1"); - doc1.put("value", 100); - docs.add(doc1); - step2Params.put("documents", docs); - step2Apply.setParameters(step2Params); - - MongoOperation step2Rollback = new MongoOperation(); - step2Rollback.setType("delete"); - step2Rollback.setCollection("stepRollbackTest"); - Map step2RollbackParams = new HashMap<>(); - step2RollbackParams.put("filter", new HashMap<>()); - step2Rollback.setParameters(step2RollbackParams); - - steps.add(new TemplateStep<>(step2Apply, step2Rollback)); - - template.setSteps(steps); + // 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"); - assertEquals(1, mongoDatabase.getCollection("stepRollbackTest").countDocuments(), - "Should have 1 document after apply"); - } - - @Test - @DisplayName("WHEN step 2 fails THEN step 1 is rolled back") - void stepBasedRollbackOnFailure() { - MongoChangeTemplate template = new MongoChangeTemplate(); - template.setChangeId("step-rollback-test"); - template.setTransactional(false); - - // Create step payload that will fail on step 2 - List> steps = new ArrayList<>(); - - // Step 1: Create collection (will succeed) - MongoOperation step1Apply = new MongoOperation(); - step1Apply.setType("createCollection"); - step1Apply.setCollection("stepRollbackTest"); - - MongoOperation step1Rollback = new MongoOperation(); - step1Rollback.setType("dropCollection"); - step1Rollback.setCollection("stepRollbackTest"); - - steps.add(new TemplateStep<>(step1Apply, step1Rollback)); - - // Step 2: Try to create a collection that already exists (will fail) - // First create the collection to cause conflict - mongoDatabase.createCollection("conflictCollection"); - - MongoOperation step2Apply = new MongoOperation(); - step2Apply.setType("createCollection"); - step2Apply.setCollection("conflictCollection"); // Already exists, will fail - - steps.add(new TemplateStep<>(step2Apply, null)); - - template.setSteps(steps); - - MongoStepExecutionException exception = assertThrows(MongoStepExecutionException.class, - () -> template.apply(mongoDatabase, null)); - - assertEquals(2, exception.getStepNumber(), "Should fail at step 2"); - assertEquals(1, exception.getCompletedStepCount(), "Should have 1 completed step before failure"); - - // Collection should be dropped due to rollback of step 1 - assertFalse(collectionExists("stepRollbackTest"), - "Collection should not exist after rollback"); - - // Clean up - mongoDatabase.getCollection("conflictCollection").drop(); } @Test - @DisplayName("WHEN step has no rollback THEN it is skipped during rollback") - void stepWithNoRollbackIsSkipped() { - // First create the collection and insert data + @DisplayName("WHEN apply is invoked with insert operation THEN documents are inserted") + void insertOperationApplySuccess() { mongoDatabase.createCollection("stepRollbackTest"); - mongoDatabase.getCollection("stepRollbackTest").insertOne(new Document("name", "Existing")); MongoChangeTemplate template = new MongoChangeTemplate(); - template.setChangeId("step-no-rollback-test"); + template.setChangeId("insert-test"); template.setTransactional(false); - // Create step payload where step 1 has no rollback - List> steps = new ArrayList<>(); - - // Step 1: Insert (no rollback defined) - MongoOperation step1Apply = new MongoOperation(); - step1Apply.setType("insert"); - step1Apply.setCollection("stepRollbackTest"); - Map step1Params = new HashMap<>(); + // 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", "NoRollback"); + doc1.put("name", "Test1"); + doc1.put("value", 100); docs.add(doc1); - step1Params.put("documents", docs); - step1Apply.setParameters(step1Params); - - // Note: no rollback defined for step 1 - steps.add(new TemplateStep<>(step1Apply, null)); + params.put("documents", docs); + insertOp.setParameters(params); + template.setApplyPayload(insertOp); - // Step 2: Create collection that already exists (will fail) - mongoDatabase.createCollection("conflictCollection2"); - - MongoOperation step2Apply = new MongoOperation(); - step2Apply.setType("createCollection"); - step2Apply.setCollection("conflictCollection2"); // Already exists, will fail - - steps.add(new TemplateStep<>(step2Apply, null)); - - template.setSteps(steps); - - assertThrows(MongoStepExecutionException.class, - () -> template.apply(mongoDatabase, null)); - - // Data from step 1 should still exist since it had no rollback - List docs2 = mongoDatabase.getCollection("stepRollbackTest") - .find() - .into(new ArrayList<>()); - assertEquals(2, docs2.size(), "Should have original + step 1 data (step 1 has no rollback)"); + template.apply(mongoDatabase, null); - // Clean up - mongoDatabase.getCollection("conflictCollection2").drop(); + assertEquals(1, mongoDatabase.getCollection("stepRollbackTest").countDocuments(), + "Should have 1 document after apply"); } @Test - @DisplayName("WHEN framework triggers rollback for step-based change THEN all steps are rolled back") - void frameworkTriggeredRollbackForSteps() { - // Set up the state as if steps had been applied + @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") )); - mongoDatabase.getCollection("stepRollbackTest").createIndex( - new Document("name", 1), - new com.mongodb.client.model.IndexOptions().name("step_index") - ); + + assertTrue(collectionExists("stepRollbackTest"), "Collection should exist before rollback"); MongoChangeTemplate template = new MongoChangeTemplate(); template.setChangeId("framework-rollback-test"); template.setTransactional(false); - // Create step payload - List> steps = new ArrayList<>(); - - // Step 1: Create collection - MongoOperation step1Apply = new MongoOperation(); - step1Apply.setType("createCollection"); - step1Apply.setCollection("stepRollbackTest"); - - MongoOperation step1Rollback = new MongoOperation(); - step1Rollback.setType("dropCollection"); - step1Rollback.setCollection("stepRollbackTest"); - - steps.add(new TemplateStep<>(step1Apply, step1Rollback)); - - // Step 2: Insert documents - MongoOperation step2Apply = new MongoOperation(); - step2Apply.setType("insert"); - step2Apply.setCollection("stepRollbackTest"); - Map step2Params = new HashMap<>(); - List> docs = new ArrayList<>(); - Map doc1 = new HashMap<>(); - doc1.put("name", "Item1"); - docs.add(doc1); - step2Params.put("documents", docs); - step2Apply.setParameters(step2Params); - - MongoOperation step2Rollback = new MongoOperation(); - step2Rollback.setType("delete"); - step2Rollback.setCollection("stepRollbackTest"); - Map step2RollbackParams = new HashMap<>(); - step2RollbackParams.put("filter", new HashMap<>()); - step2Rollback.setParameters(step2RollbackParams); - - steps.add(new TemplateStep<>(step2Apply, step2Rollback)); - - // Step 3: Create index - MongoOperation step3Apply = new MongoOperation(); - step3Apply.setType("createIndex"); - step3Apply.setCollection("stepRollbackTest"); - Map step3Params = new HashMap<>(); - Map keys = new HashMap<>(); - keys.put("name", 1); - step3Params.put("keys", keys); - Map step3Options = new HashMap<>(); - step3Options.put("name", "step_index"); - step3Params.put("options", step3Options); - step3Apply.setParameters(step3Params); - - MongoOperation step3Rollback = new MongoOperation(); - step3Rollback.setType("dropIndex"); - step3Rollback.setCollection("stepRollbackTest"); - Map step3RollbackParams = new HashMap<>(); - step3RollbackParams.put("indexName", "step_index"); - step3Rollback.setParameters(step3RollbackParams); - - steps.add(new TemplateStep<>(step3Apply, step3Rollback)); - - template.setSteps(steps); - - assertTrue(collectionExists("stepRollbackTest"), "Collection should exist before rollback"); + // Set rollback payload - drop collection + MongoOperation dropCollectionOp = new MongoOperation(); + dropCollectionOp.setType("dropCollection"); + dropCollectionOp.setCollection("stepRollbackTest"); + template.setRollbackPayload(dropCollectionOp); template.rollback(mongoDatabase, null); - // Step 3 rollback drops index, Step 2 rollback deletes docs, Step 1 rollback drops collection assertFalse(collectionExists("stepRollbackTest"), "Collection should not exist after framework-triggered rollback"); } From 90be79237e3287c32e65b1828d2c9cd3cd9ad301 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Sun, 15 Feb 2026 12:51:49 +0000 Subject: [PATCH 5/8] docs: update docs --- README.md | 220 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 116 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index 8e35bf4..d5e0bde 100644 --- a/README.md +++ b/README.md @@ -33,8 +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 -- **Steps format** — Group multiple operations with paired rollbacks for atomic changes -- **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 @@ -65,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 @@ -73,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 @@ -88,22 +94,28 @@ 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. Multiple operations with rollback using steps +### 3. Multiple operations with rollback -For changes that require multiple operations with paired rollbacks, use the `steps` format: +Group multiple operations in a single change. If a step fails, the framework automatically rolls back completed steps in reverse order. **Example: `_0003__setup_products.yaml`** @@ -159,11 +171,7 @@ steps: ## 📄 YAML Structure -The template supports two formats: **simple format** for single operations, and **steps format** for multiple operations with paired rollbacks. - -### Simple Format - -Use this format for single operations: +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 @@ -182,33 +190,7 @@ template: MongoChangeTemplate targetSystem: id: "mongodb" -# Required: Single operation to apply -apply: - type: - collection: - parameters: - # Operation-specific parameters - -# Optional: Single rollback operation -rollback: - type: - collection: - parameters: - # Operation-specific parameters -``` - -### Steps Format - -Use this format when you need multiple operations with paired rollbacks: - -```yaml -id: my-change-id -transactional: false -template: MongoChangeTemplate -targetSystem: - id: "mongodb" - -# List of steps, each with an apply and optional rollback +# Required: List of steps, each with an apply and optional rollback steps: - apply: type: @@ -229,102 +211,132 @@ steps: 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 ``` -### Multiple Operations with Steps +### Complete Example — Multiple Steps -When you need multiple operations with paired rollbacks: +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 From a36ac3ec199c4e1b24ee4d4b57e6581488e3abd4 Mon Sep 17 00:00:00 2001 From: Antonio Perez Dieppa Date: Wed, 18 Feb 2026 20:27:56 +0000 Subject: [PATCH 6/8] refactor: flamingock core version --- build.gradle.kts | 37 ++----------------------------------- 1 file changed, 2 insertions(+), 35 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 4723f16..e9ad94b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,47 +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 source = "default" - val flamingockVersionAsParameter: String? = project.findProperty("flamingockVersion")?.toString() - val flamingockVersion: String = if (flamingockVersionAsParameter != null) { - source = "parameter" - flamingockVersionAsParameter - } else { - // Default to development version for local builds - // Use -PflamingockVersion=X.Y.Z to override, or set to fetch from Maven Central - val useLatestFromCentral = project.findProperty("useLatestFromCentral")?.toString()?.toBoolean() ?: false - if (useLatestFromCentral) { - source = "maven-central" - 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) - } - } else { - // Default local development version - "1.1.0-rc.2" - } - } - logger.lifecycle("Building with flamingock version[$source]: $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() From a6af474f0d6113ad59a3cc893d5de189d6cff403 Mon Sep 17 00:00:00 2001 From: Antonio Perez Dieppa Date: Thu, 19 Feb 2026 07:36:32 +0000 Subject: [PATCH 7/8] chore: update name in settings.gradle.kts --- .claude/settings.local.json | 19 +++++++++++++++++++ settings.gradle.kts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.local.json 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/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" From 830ad68d264e43a55a63830f380d27021348af3b Mon Sep 17 00:00:00 2001 From: Antonio Perez Dieppa Date: Thu, 19 Feb 2026 08:33:43 +0000 Subject: [PATCH 8/8] chore: quality assesment(from claude) --- ANALYSIS.md | 324 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 ANALYSIS.md 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**.