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
*
- * - Apply operations execute sequentially in order
- * - Rollback operations execute sequentially in the order defined by the user
- * - The framework handles rollback invocation on failure
- * - In transactional mode, MongoDB transaction provides atomicity
+ * - Step-based format: Each step's apply operation executes sequentially. On failure,
+ * rollback operations for completed steps execute in reverse order.
+ * - Legacy format: Apply operations execute sequentially in order.
+ * Rollback operations execute in the order defined by the user.
+ * - In transactional mode, MongoDB transaction provides atomicity.
*
*
* @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