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"