diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java index 9890ec4ae..7a04eca65 100644 --- a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/MongoChangeTemplate.java @@ -21,9 +21,75 @@ import io.flamingock.api.annotations.Nullable; import io.flamingock.api.annotations.Rollback; import io.flamingock.api.template.AbstractChangeTemplate; +import io.flamingock.internal.util.log.FlamingockLoggerFactory; +import io.flamingock.template.mongodb.model.MongoApplyPayload; import io.flamingock.template.mongodb.model.MongoOperation; +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; -public class MongoChangeTemplate extends AbstractChangeTemplate { +import java.util.ArrayList; +import java.util.List; + +/** + * MongoDB Change Template for executing declarative MongoDB operations defined in YAML. + * + *

Apply Behavior

+ *

The {@link #apply} method executes all operations defined in the payload sequentially. + * The behavior differs based on the transactional mode:

+ * + *

Transactional Mode ({@code transactional: true})

+ * + * + *

Non-Transactional Mode ({@code transactional: false})

+ * + * + *

YAML Example

+ *
{@code
+ * id: create-orders-collection
+ * transactional: false
+ * template: MongoChangeTemplate
+ * targetSystem:
+ *   id: "mongodb"
+ * apply:
+ *   operations:
+ *     - type: createCollection
+ *       collection: orders
+ *       rollback:
+ *         type: dropCollection
+ *         collection: orders
+ *
+ *     - type: insert
+ *       collection: orders
+ *       parameters:
+ *         documents:
+ *           - orderId: "ORD-001"
+ *             customer: "John Doe"
+ *       rollback:
+ *         type: delete
+ *         collection: orders
+ *         parameters:
+ *           filter: {}
+ * }
+ * + * @see MongoOperation + * @see MongoApplyPayload + */ +public class MongoChangeTemplate extends AbstractChangeTemplate { + + private static final Logger logger = FlamingockLoggerFactory.getLogger(MongoChangeTemplate.class); public MongoChangeTemplate() { super(MongoOperation.class); @@ -34,7 +100,8 @@ public void apply(MongoDatabase db, @Nullable ClientSession clientSession) { if (this.isTransactional && clientSession == null) { throw new IllegalArgumentException(String.format("Transactional change[%s] requires transactional ecosystem with ClientSession", changeId)); } - executeOp(db, applyPayload, clientSession); + validatePayload(applyPayload, changeId); + executeOperationsWithAutoRollback(db, applyPayload, clientSession); } @Rollback @@ -42,11 +109,80 @@ public void rollback(MongoDatabase db, @Nullable ClientSession clientSession) { if (this.isTransactional && clientSession == null) { throw new IllegalArgumentException(String.format("Transactional change[%s] requires transactional ecosystem with ClientSession", changeId)); } - executeOp(db, rollbackPayload, clientSession); + validatePayload(rollbackPayload, changeId + ".rollback"); + executeRollbackOperations(db, applyPayload, clientSession); + } + + private void validatePayload(MongoApplyPayload payload, String entityId) { + if (payload == null || payload.getOperations() == null) { + return; + } + + List errors = new ArrayList<>(); + List operations = payload.getOperations(); + for (int i = 0; i < operations.size(); i++) { + String opId = entityId + ".operations[" + i + "]"; + errors.addAll(MongoOperationValidator.validate(operations.get(i), opId)); + } + + if (!errors.isEmpty()) { + throw new MongoTemplateValidationException(errors); + } } - private void executeOp(MongoDatabase db, MongoOperation op, ClientSession clientSession) { - op.getOperator(db).apply(clientSession); + private void executeOperationsWithAutoRollback(MongoDatabase db, MongoApplyPayload payload, ClientSession clientSession) { + if (payload == null) { + return; + } + + List operations = payload.getOperations(); + + // Transactional, MongoDB handles rollback + if (this.isTransactional && clientSession != null) { + for (MongoOperation op : operations) { + op.getOperator(db).apply(clientSession); + } + return; + } + + // Non-transactional: auto-rollback on failure + List successfulOps = new ArrayList<>(); + for (MongoOperation op : operations) { + try { + op.getOperator(db).apply(clientSession); + successfulOps.add(op); + } catch (Exception e) { + rollbackSuccessfulOperations(db, successfulOps, clientSession); + throw e; + } + } + } + + private void rollbackSuccessfulOperations(MongoDatabase db, List successfulOps, ClientSession clientSession) { + for (int i = successfulOps.size() - 1; i >= 0; i--) { + MongoOperation op = successfulOps.get(i); + if (op.getRollback() != null) { + try { + op.getRollback().getOperator(db).apply(clientSession); + } catch (Exception rollbackEx) { + logger.warn("Rollback failed for operation: {}", op, rollbackEx); + } + } + } + } + + private void executeRollbackOperations(MongoDatabase db, MongoApplyPayload payload, ClientSession clientSession) { + if (payload == null) { + return; + } + + List operations = payload.getOperations(); + for (int i = operations.size() - 1; i >= 0; i--) { + MongoOperation op = operations.get(i); + if (op.getRollback() != null) { + op.getRollback().getOperator(db).apply(clientSession); + } + } } } \ No newline at end of file diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/CreateViewOptionsMapper.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/CreateViewOptionsMapper.java new file mode 100644 index 000000000..a528bbe3b --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/CreateViewOptionsMapper.java @@ -0,0 +1,37 @@ +/* + * 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.mapper; + +import com.mongodb.client.model.CreateViewOptions; + +import java.util.Map; + +import static io.flamingock.template.mongodb.mapper.MapperUtil.getCollation; + +public final class CreateViewOptionsMapper { + + private CreateViewOptionsMapper() {} + + public static CreateViewOptions map(Map options) { + CreateViewOptions result = new CreateViewOptions(); + + if (options.containsKey("collation")) { + result.collation(getCollation(options, "collation")); + } + + return result; + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/IndexOptionsMapper.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/IndexOptionsMapper.java index 8f119977e..c9e28b587 100644 --- a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/IndexOptionsMapper.java +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/IndexOptionsMapper.java @@ -30,7 +30,9 @@ import java.util.concurrent.TimeUnit; -public class IndexOptionsMapper { +public final class IndexOptionsMapper { + + private IndexOptionsMapper() {} public static IndexOptions mapToIndexOptions(Map options) { IndexOptions indexOptions = new IndexOptions(); @@ -98,10 +100,5 @@ public static IndexOptions mapToIndexOptions(Map options) { return indexOptions; } - - - // Utility methods for safe type checking with exception handling - - } diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/InsertOptionsMapper.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/InsertOptionsMapper.java index f2c9aacdf..96a2f56dc 100644 --- a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/InsertOptionsMapper.java +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/InsertOptionsMapper.java @@ -19,13 +19,14 @@ import com.mongodb.client.model.InsertOneOptions; import java.util.Map; -import java.util.concurrent.TimeUnit; import static io.flamingock.template.mongodb.mapper.MapperUtil.getBoolean; -public class InsertOptionsMapper { +public final class InsertOptionsMapper { - public static InsertOneOptions mapToInertOneOptions(Map options) { + private InsertOptionsMapper() {} + + public static InsertOneOptions mapToInsertOneOptions(Map options) { InsertOneOptions insertOneOptions = new InsertOneOptions(); if (options.containsKey("bypassDocumentValidation")) { @@ -35,18 +36,18 @@ public static InsertOneOptions mapToInertOneOptions(Map options) return insertOneOptions; } - public static InsertManyOptions mapToInertManyOptions(Map options) { - InsertManyOptions insertOneOptions = new InsertManyOptions(); + public static InsertManyOptions mapToInsertManyOptions(Map options) { + InsertManyOptions insertManyOptions = new InsertManyOptions(); if (options.containsKey("bypassDocumentValidation")) { - insertOneOptions.bypassDocumentValidation(getBoolean(options, "bypassDocumentValidation")); + insertManyOptions.bypassDocumentValidation(getBoolean(options, "bypassDocumentValidation")); } if (options.containsKey("ordered")) { - insertOneOptions.bypassDocumentValidation(getBoolean(options, "ordered")); + insertManyOptions.ordered(getBoolean(options, "ordered")); } - return insertOneOptions; + return insertManyOptions; } } diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/MapperUtil.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/MapperUtil.java index 6a136b418..f9f07e66e 100644 --- a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/MapperUtil.java +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/MapperUtil.java @@ -16,10 +16,12 @@ package io.flamingock.template.mongodb.mapper; import com.mongodb.client.model.Collation; +import org.bson.BsonArray; import org.bson.BsonDocument; import org.bson.BsonValue; import org.bson.conversions.Bson; +import java.util.List; import java.util.Map; public final class MapperUtil { @@ -101,7 +103,9 @@ public static BsonDocument toBsonDocument(Map map) { // Converts Java types into BSON types @SuppressWarnings("unchecked") public static BsonValue toBsonValue(Object value) { - if (value instanceof String) { + if (value == null) { + return org.bson.BsonNull.VALUE; + } else if (value instanceof String) { return new org.bson.BsonString((String) value); } else if (value instanceof Integer) { return new org.bson.BsonInt32((Integer) value); @@ -111,9 +115,20 @@ public static BsonValue toBsonValue(Object value) { return new org.bson.BsonDouble((Double) value); } else if (value instanceof Boolean) { return new org.bson.BsonBoolean((Boolean) value); + } else if (value instanceof List) { + return toBsonArray((List) value); } else if (value instanceof Map) { return toBsonDocument((Map) value); } throw new IllegalArgumentException("Unsupported BSON type: " + value.getClass().getSimpleName()); } + + // Converts a List to BsonArray + public static BsonArray toBsonArray(List list) { + BsonArray array = new BsonArray(); + for (Object item : list) { + array.add(toBsonValue(item)); + } + return array; + } } diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/RenameCollectionOptionsMapper.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/RenameCollectionOptionsMapper.java new file mode 100644 index 000000000..ade56d87b --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/RenameCollectionOptionsMapper.java @@ -0,0 +1,37 @@ +/* + * 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.mapper; + +import com.mongodb.client.model.RenameCollectionOptions; + +import java.util.Map; + +import static io.flamingock.template.mongodb.mapper.MapperUtil.getBoolean; + +public final class RenameCollectionOptionsMapper { + + private RenameCollectionOptionsMapper() {} + + public static RenameCollectionOptions map(Map options) { + RenameCollectionOptions result = new RenameCollectionOptions(); + + if (options.containsKey("dropTarget")) { + result.dropTarget(getBoolean(options, "dropTarget")); + } + + return result; + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/UpdateOptionsMapper.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/UpdateOptionsMapper.java new file mode 100644 index 000000000..642bc1422 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/UpdateOptionsMapper.java @@ -0,0 +1,63 @@ +/* + * 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.mapper; + +import com.mongodb.client.model.UpdateOptions; + +import java.util.Map; + +import static io.flamingock.template.mongodb.mapper.MapperUtil.getBoolean; +import static io.flamingock.template.mongodb.mapper.MapperUtil.getCollation; + +public final class UpdateOptionsMapper { + + private UpdateOptionsMapper() {} + + public static UpdateOptions mapToUpdateOptions(Map options) { + UpdateOptions updateOptions = new UpdateOptions(); + + if (options.containsKey("upsert")) { + updateOptions.upsert(getBoolean(options, "upsert")); + } + + if (options.containsKey("bypassDocumentValidation")) { + updateOptions.bypassDocumentValidation(getBoolean(options, "bypassDocumentValidation")); + } + + if (options.containsKey("collation")) { + updateOptions.collation(getCollation(options, "collation")); + } + + if (options.containsKey("arrayFilters")) { + Object arrayFilters = options.get("arrayFilters"); + if (arrayFilters instanceof java.util.List) { + java.util.List bsonFilters = new java.util.ArrayList<>(); + for (Object filter : (java.util.List) arrayFilters) { + if (filter instanceof Map) { + @SuppressWarnings("unchecked") + Map filterMap = (Map) filter; + bsonFilters.add(MapperUtil.toBsonDocument(filterMap)); + } else if (filter instanceof org.bson.conversions.Bson) { + bsonFilters.add((org.bson.conversions.Bson) filter); + } + } + updateOptions.arrayFilters(bsonFilters); + } + } + + return updateOptions; + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoApplyPayload.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoApplyPayload.java new file mode 100644 index 000000000..2ce371274 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoApplyPayload.java @@ -0,0 +1,115 @@ +/* + * 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 java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Container class for MongoDB apply/rollback payload that supports both: + *
    + *
  • Single operation format (backward compatible): + *
    + *     apply:
    + *       type: createCollection
    + *       collection: users
    + *     
    + *
  • + *
  • Multiple operations format: + *
    + *     apply:
    + *       operations:
    + *         - type: createCollection
    + *           collection: users
    + *         - type: createIndex
    + *           collection: users
    + *           parameters:
    + *             keys: { name: 1 }
    + *     
    + *
  • + *
+ */ +public class MongoApplyPayload { + + private List operations; + + // For backward compatibility + private String type; + private String collection; + private Map parameters; + + /** + * Returns the list of operations to execute. + * Handles both formats: + *
    + *
  • Multiple: returns the operations list directly
  • + *
  • Single: wraps the single operation in a list
  • + *
+ * + * @return list of operations to execute, never null + */ + public List getOperations() { + if (operations != null && !operations.isEmpty()) { + return operations; + } + if (type != null) { + MongoOperation singleOp = new MongoOperation(); + singleOp.setType(type); + singleOp.setCollection(collection); + singleOp.setParameters(parameters != null ? parameters : new HashMap<>()); + return Collections.singletonList(singleOp); + } + return Collections.emptyList(); + } + + public void setOperations(List operations) { + this.operations = operations; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getCollection() { + return collection; + } + + public void setCollection(String collection) { + this.collection = collection; + } + + public Map getParameters() { + return parameters; + } + + public void setParameters(Map parameters) { + this.parameters = parameters; + } + + @Override + public String toString() { + if (operations != null && !operations.isEmpty()) { + return "MongoApplyPayload{operations=" + operations + "}"; + } + return "MongoApplyPayload{type='" + type + "', collection='" + collection + "', parameters=" + parameters + "}"; + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperation.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperation.java index 4d931e157..0ae8707df 100644 --- a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperation.java +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperation.java @@ -30,6 +30,7 @@ public class MongoOperation { private String type; private String collection; private Map parameters; + private MongoOperation rollback; public String getType() { return type; } @@ -43,6 +44,10 @@ public class MongoOperation { public void setParameters(Map parameters) { this.parameters = parameters; } + public MongoOperation getRollback() { return rollback; } + + public void setRollback(MongoOperation rollback) { this.rollback = rollback; } + @SuppressWarnings("unchecked") public List getDocuments() { return ((List>) parameters.get("documents")) @@ -69,6 +74,50 @@ public Document getFilter() { return new Document((Map) parameters.get("filter")); } + public String getIndexName() { + Object value = parameters.get("indexName"); + return value != null ? (String) value : null; + } + + public String getTarget() { + return (String) parameters.get("target"); + } + + @SuppressWarnings("unchecked") + public Document getValidator() { + Object value = parameters.get("validator"); + return value != null ? new Document((Map) value) : null; + } + + public String getValidationLevel() { + return (String) parameters.get("validationLevel"); + } + + public String getValidationAction() { + return (String) parameters.get("validationAction"); + } + + public String getViewOn() { + return (String) parameters.get("viewOn"); + } + + @SuppressWarnings("unchecked") + public List getPipeline() { + List> rawPipeline = (List>) parameters.get("pipeline"); + return rawPipeline != null + ? rawPipeline.stream().map(Document::new).collect(Collectors.toList()) + : null; + } + + @SuppressWarnings("unchecked") + public Document getUpdate() { + return new Document((Map) parameters.get("update")); + } + + public boolean isMulti() { + Object multi = parameters.get("multi"); + return multi != null && (Boolean) multi; + } public MongoOperator getOperator(MongoDatabase db) { return MongoOperationType.getFromValue(getType()).getOperator(db, this); @@ -76,10 +125,13 @@ public MongoOperator getOperator(MongoDatabase db) { @Override public String toString() { - final StringBuffer sb = new StringBuffer("MongoOperation{"); + final StringBuilder sb = new StringBuilder("MongoOperation{"); sb.append("type='").append(type).append('\''); sb.append(", collection='").append(collection).append('\''); sb.append(", parameters=").append(parameters); + if (rollback != null) { + sb.append(", rollback=").append(rollback); + } sb.append('}'); return sb.toString(); } diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperationType.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperationType.java index f54c893e2..76ded4af8 100644 --- a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperationType.java +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperationType.java @@ -18,8 +18,16 @@ import com.mongodb.client.MongoDatabase; import io.flamingock.template.mongodb.model.operator.CreateCollectionOperator; import io.flamingock.template.mongodb.model.operator.CreateIndexOperator; +import io.flamingock.template.mongodb.model.operator.CreateViewOperator; +import io.flamingock.template.mongodb.model.operator.DeleteOperator; +import io.flamingock.template.mongodb.model.operator.DropCollectionOperator; +import io.flamingock.template.mongodb.model.operator.DropIndexOperator; +import io.flamingock.template.mongodb.model.operator.DropViewOperator; import io.flamingock.template.mongodb.model.operator.InsertOperator; +import io.flamingock.template.mongodb.model.operator.ModifyCollectionOperator; import io.flamingock.template.mongodb.model.operator.MongoOperator; +import io.flamingock.template.mongodb.model.operator.RenameCollectionOperator; +import io.flamingock.template.mongodb.model.operator.UpdateOperator; import java.util.Arrays; import java.util.function.BiFunction; @@ -28,7 +36,15 @@ public enum MongoOperationType { CREATE_COLLECTION("createCollection", CreateCollectionOperator::new), CREATE_INDEX("createIndex", CreateIndexOperator::new), - INSERT("insert", InsertOperator::new); + INSERT("insert", InsertOperator::new), + UPDATE("update", UpdateOperator::new), + DELETE("delete", DeleteOperator::new), + DROP_COLLECTION("dropCollection", DropCollectionOperator::new), + DROP_INDEX("dropIndex", DropIndexOperator::new), + RENAME_COLLECTION("renameCollection", RenameCollectionOperator::new), + MODIFY_COLLECTION("modifyCollection", ModifyCollectionOperator::new), + CREATE_VIEW("createView", CreateViewOperator::new), + DROP_VIEW("dropView", DropViewOperator::new); private final String value; private final BiFunction createOperatorFunction; diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/CreateViewOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/CreateViewOperator.java new file mode 100644 index 000000000..09633971d --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/CreateViewOperator.java @@ -0,0 +1,35 @@ +/* + * 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.operator; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.CreateViewOptions; +import io.flamingock.template.mongodb.mapper.CreateViewOptionsMapper; +import io.flamingock.template.mongodb.model.MongoOperation; + +public class CreateViewOperator extends MongoOperator { + + public CreateViewOperator(MongoDatabase mongoDatabase, MongoOperation operation) { + super(mongoDatabase, operation, false); + } + + @Override + protected void applyInternal(ClientSession clientSession) { + CreateViewOptions options = CreateViewOptionsMapper.map(op.getOptions()); + mongoDatabase.createView(op.getCollection(), op.getViewOn(), op.getPipeline(), options); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DeleteOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DeleteOperator.java new file mode 100644 index 000000000..0710038b9 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DeleteOperator.java @@ -0,0 +1,41 @@ +/* + * 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.operator; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import org.bson.Document; + +public class DeleteOperator extends MongoOperator { + + public DeleteOperator(MongoDatabase mongoDatabase, MongoOperation operation) { + super(mongoDatabase, operation, true); + } + + @Override + protected void applyInternal(ClientSession clientSession) { + MongoCollection collection = mongoDatabase.getCollection(op.getCollection()); + Document filter = op.getFilter(); + + if (clientSession != null) { + collection.deleteMany(clientSession, filter); + } else { + collection.deleteMany(filter); + } + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropCollectionOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropCollectionOperator.java new file mode 100644 index 000000000..e27f59363 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropCollectionOperator.java @@ -0,0 +1,32 @@ +/* + * 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.operator; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; + +public class DropCollectionOperator extends MongoOperator { + + public DropCollectionOperator(MongoDatabase mongoDatabase, MongoOperation operation) { + super(mongoDatabase, operation, false); + } + + @Override + protected void applyInternal(ClientSession clientSession) { + mongoDatabase.getCollection(op.getCollection()).drop(); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropIndexOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropIndexOperator.java new file mode 100644 index 000000000..504de9889 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropIndexOperator.java @@ -0,0 +1,37 @@ +/* + * 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.operator; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; + +public class DropIndexOperator extends MongoOperator { + + public DropIndexOperator(MongoDatabase mongoDatabase, MongoOperation operation) { + super(mongoDatabase, operation, false); + } + + @Override + protected void applyInternal(ClientSession clientSession) { + String indexName = op.getIndexName(); + if (indexName != null) { + mongoDatabase.getCollection(op.getCollection()).dropIndex(indexName); + } else { + mongoDatabase.getCollection(op.getCollection()).dropIndex(op.getKeys()); + } + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropViewOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropViewOperator.java new file mode 100644 index 000000000..369bc9b2d --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropViewOperator.java @@ -0,0 +1,32 @@ +/* + * 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.operator; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; + +public class DropViewOperator extends MongoOperator { + + public DropViewOperator(MongoDatabase mongoDatabase, MongoOperation operation) { + super(mongoDatabase, operation, false); + } + + @Override + protected void applyInternal(ClientSession clientSession) { + mongoDatabase.getCollection(op.getCollection()).drop(); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/InsertOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/InsertOperator.java index 9d866b9b6..6673f2cc2 100644 --- a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/InsertOperator.java +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/InsertOperator.java @@ -24,8 +24,6 @@ import io.flamingock.template.mongodb.model.MongoOperation; import org.bson.Document; -import java.util.List; - public class InsertOperator extends MongoOperator { @@ -36,7 +34,7 @@ public InsertOperator(MongoDatabase mongoDatabase, MongoOperation operation) { @Override protected void applyInternal(ClientSession clientSession) { MongoCollection collection = mongoDatabase.getCollection(op.getCollection()); - if(op.getDocuments() == null || op.getDocuments().size() == 0) { + if(op.getDocuments() == null || op.getDocuments().isEmpty()) { return; } @@ -49,16 +47,16 @@ protected void applyInternal(ClientSession clientSession) { private void insertMany(ClientSession clientSession, MongoCollection collection) { if(clientSession != null) { - if(op.getOptions().size() != 0) { - InsertManyOptions insertManyOptions = InsertOptionsMapper.mapToInertManyOptions(op.getOptions()); + if(!op.getOptions().isEmpty()) { + InsertManyOptions insertManyOptions = InsertOptionsMapper.mapToInsertManyOptions(op.getOptions()); collection.insertMany(clientSession, op.getDocuments(), insertManyOptions); } else { collection.insertMany(clientSession, op.getDocuments()); } } else { - if(op.getOptions().size() != 0) { - InsertManyOptions insertManyOptions = InsertOptionsMapper.mapToInertManyOptions(op.getOptions()); + if(!op.getOptions().isEmpty()) { + InsertManyOptions insertManyOptions = InsertOptionsMapper.mapToInsertManyOptions(op.getOptions()); collection.insertMany(op.getDocuments(), insertManyOptions); } else { collection.insertMany(op.getDocuments()); @@ -69,16 +67,16 @@ private void insertMany(ClientSession clientSession, MongoCollection c private void insertOne(ClientSession clientSession, MongoCollection collection) { if(clientSession != null) { - if(op.getOptions().size() != 0) { - InsertOneOptions insertOneOptions = InsertOptionsMapper.mapToInertOneOptions(op.getOptions()); + if(!op.getOptions().isEmpty()) { + InsertOneOptions insertOneOptions = InsertOptionsMapper.mapToInsertOneOptions(op.getOptions()); collection.insertOne(clientSession, op.getDocuments().get(0), insertOneOptions); } else { collection.insertOne(clientSession, op.getDocuments().get(0)); } } else { - if(op.getOptions().size() != 0) { - InsertOneOptions insertOneOptions = InsertOptionsMapper.mapToInertOneOptions(op.getOptions()); + if(!op.getOptions().isEmpty()) { + InsertOneOptions insertOneOptions = InsertOptionsMapper.mapToInsertOneOptions(op.getOptions()); collection.insertOne(op.getDocuments().get(0), insertOneOptions); } else { collection.insertOne(op.getDocuments().get(0)); diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/ModifyCollectionOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/ModifyCollectionOperator.java new file mode 100644 index 000000000..fb0323ee2 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/ModifyCollectionOperator.java @@ -0,0 +1,43 @@ +/* + * 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.operator; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import org.bson.Document; + +public class ModifyCollectionOperator extends MongoOperator { + + public ModifyCollectionOperator(MongoDatabase mongoDatabase, MongoOperation operation) { + super(mongoDatabase, operation, false); + } + + @Override + protected void applyInternal(ClientSession clientSession) { + Document command = new Document("collMod", op.getCollection()); + if (op.getValidator() != null) { + command.append("validator", op.getValidator()); + } + if (op.getValidationLevel() != null) { + command.append("validationLevel", op.getValidationLevel()); + } + if (op.getValidationAction() != null) { + command.append("validationAction", op.getValidationAction()); + } + mongoDatabase.runCommand(command); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/MongoOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/MongoOperator.java index 7e30f76ec..5a738b6e5 100644 --- a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/MongoOperator.java +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/MongoOperator.java @@ -44,17 +44,17 @@ private void logOperation(boolean withClientSession) { if (transactional) { if (withClientSession) { + logger.debug("Applying transactional operation [{}] with transaction", simpleName); + } else { logger.warn("{} is a transactional operation but is not being applied within a transaction. " + "Recommend marking Change as transactional.", simpleName); - } else { - logger.debug("Applying operation [{}] with transaction: ", simpleName); } } else { - if(withClientSession) { + if (withClientSession) { logger.info("{} is not transactional, but Change has been marked as transactional. Transaction ignored.", simpleName); } else { - logger.debug("Applying non-transactional operation [{}]: ", simpleName); + logger.debug("Applying non-transactional operation [{}]", simpleName); } } } diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/RenameCollectionOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/RenameCollectionOperator.java new file mode 100644 index 000000000..af50238a6 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/RenameCollectionOperator.java @@ -0,0 +1,37 @@ +/* + * 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.operator; + +import com.mongodb.MongoNamespace; +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.RenameCollectionOptions; +import io.flamingock.template.mongodb.mapper.RenameCollectionOptionsMapper; +import io.flamingock.template.mongodb.model.MongoOperation; + +public class RenameCollectionOperator extends MongoOperator { + + public RenameCollectionOperator(MongoDatabase mongoDatabase, MongoOperation operation) { + super(mongoDatabase, operation, false); + } + + @Override + protected void applyInternal(ClientSession clientSession) { + MongoNamespace target = new MongoNamespace(mongoDatabase.getName(), op.getTarget()); + RenameCollectionOptions options = RenameCollectionOptionsMapper.map(op.getOptions()); + mongoDatabase.getCollection(op.getCollection()).renameCollection(target, options); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/UpdateOperator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/UpdateOperator.java new file mode 100644 index 000000000..17efae095 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/UpdateOperator.java @@ -0,0 +1,83 @@ +/* + * 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.operator; + +import com.mongodb.client.ClientSession; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import com.mongodb.client.model.UpdateOptions; +import io.flamingock.template.mongodb.mapper.UpdateOptionsMapper; +import io.flamingock.template.mongodb.model.MongoOperation; +import org.bson.Document; + +public class UpdateOperator extends MongoOperator { + + public UpdateOperator(MongoDatabase mongoDatabase, MongoOperation operation) { + super(mongoDatabase, operation, true); + } + + @Override + protected void applyInternal(ClientSession clientSession) { + MongoCollection collection = mongoDatabase.getCollection(op.getCollection()); + Document filter = op.getFilter(); + Document update = op.getUpdate(); + boolean multi = op.isMulti(); + + if (multi) { + updateMany(clientSession, collection, filter, update); + } else { + updateOne(clientSession, collection, filter, update); + } + } + + private void updateOne(ClientSession clientSession, MongoCollection collection, + Document filter, Document update) { + if (clientSession != null) { + if (!op.getOptions().isEmpty()) { + UpdateOptions updateOptions = UpdateOptionsMapper.mapToUpdateOptions(op.getOptions()); + collection.updateOne(clientSession, filter, update, updateOptions); + } else { + collection.updateOne(clientSession, filter, update); + } + } else { + if (!op.getOptions().isEmpty()) { + UpdateOptions updateOptions = UpdateOptionsMapper.mapToUpdateOptions(op.getOptions()); + collection.updateOne(filter, update, updateOptions); + } else { + collection.updateOne(filter, update); + } + } + } + + private void updateMany(ClientSession clientSession, MongoCollection collection, + Document filter, Document update) { + if (clientSession != null) { + if (!op.getOptions().isEmpty()) { + UpdateOptions updateOptions = UpdateOptionsMapper.mapToUpdateOptions(op.getOptions()); + collection.updateMany(clientSession, filter, update, updateOptions); + } else { + collection.updateMany(clientSession, filter, update); + } + } else { + if (!op.getOptions().isEmpty()) { + UpdateOptions updateOptions = UpdateOptionsMapper.mapToUpdateOptions(op.getOptions()); + collection.updateMany(filter, update, updateOptions); + } else { + collection.updateMany(filter, update); + } + } + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java new file mode 100644 index 000000000..e61a1b7bd --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java @@ -0,0 +1,426 @@ +/* + * 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.validation; + +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.MongoOperationType; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Validates MongoDB template operations before execution. + * + *

This validator checks for required fields, valid parameter types, + * and operation-specific constraints. All errors are collected and returned + * together rather than failing on the first error.

+ * + *

Common Validations (All Operations)

+ *
    + *
  • type - Required, must be a known operation type from {@link MongoOperationType}
  • + *
  • collection - Required, cannot be empty, cannot contain '$' or null character
  • + *
  • rollback - If present, validated recursively with the same rules
  • + *
+ * + *

Operation-Specific Validations

+ * + *

createCollection

+ *

Only requires common validations (collection name).

+ *
{@code
+ * - type: createCollection
+ *   collection: users
+ * }
+ * + *

dropCollection

+ *

Only requires common validations (collection name).

+ *
{@code
+ * - type: dropCollection
+ *   collection: users
+ * }
+ * + *

insert

+ *

Requires {@code parameters.documents} as a non-empty list with no null elements.

+ *
{@code
+ * - type: insert
+ *   collection: users
+ *   parameters:
+ *     documents:
+ *       - name: "John"
+ *         email: "john@example.com"
+ * }
+ * + *

update

+ *

Requires {@code parameters.filter} and {@code parameters.update}. Optionally supports {@code parameters.multi} + * (boolean, default false) to update multiple documents.

+ *
{@code
+ * - type: update
+ *   collection: users
+ *   parameters:
+ *     filter:
+ *       status: "inactive"
+ *     update:
+ *       $set:
+ *         status: "archived"
+ *     multi: true
+ * }
+ * + *

delete

+ *

Requires {@code parameters.filter}. Filter can be empty ({@code {}}) to delete all documents.

+ *
{@code
+ * - type: delete
+ *   collection: users
+ *   parameters:
+ *     filter:
+ *       status: "inactive"
+ * }
+ * + *

createIndex

+ *

Requires {@code parameters.keys} as a non-empty map defining the index fields.

+ *
{@code
+ * - type: createIndex
+ *   collection: users
+ *   parameters:
+ *     keys:
+ *       email: 1
+ *     options:
+ *       unique: true
+ * }
+ * + *

dropIndex

+ *

Requires either {@code parameters.indexName} OR {@code parameters.keys} (at least one).

+ *
{@code
+ * - type: dropIndex
+ *   collection: users
+ *   parameters:
+ *     indexName: "email_1"
+ * }
+ * + *

renameCollection

+ *

Requires {@code parameters.target} as a non-empty string for the new collection name.

+ *
{@code
+ * - type: renameCollection
+ *   collection: old_users
+ *   parameters:
+ *     target: users_archive
+ * }
+ * + *

createView

+ *

Requires {@code parameters.viewOn} (source collection) and {@code parameters.pipeline} (aggregation pipeline as list).

+ *
{@code
+ * - type: createView
+ *   collection: active_users_view
+ *   parameters:
+ *     viewOn: users
+ *     pipeline:
+ *       - $match:
+ *           status: "active"
+ * }
+ * + *

dropView

+ *

Only requires common validations (collection/view name).

+ *
{@code
+ * - type: dropView
+ *   collection: active_users_view
+ * }
+ * + *

modifyCollection

+ *

Only requires common validations. Used for modifying collection options like validation rules.

+ *
{@code
+ * - type: modifyCollection
+ *   collection: users
+ *   parameters:
+ *     validator:
+ *       $jsonSchema:
+ *         required: ["email"]
+ * }
+ * + * @see MongoOperation + * @see MongoOperationType + * @see ValidationError + */ +public final class MongoOperationValidator { + + private static final String MONGO_OPERATION = "MongoOperation"; + + private MongoOperationValidator() { + } + + /** + * Validates a MongoDB operation and returns all validation errors. + * + * @param operation the operation to validate + * @param entityId the identifier for error reporting (e.g., "changeId.operations[0]") + * @return list of validation errors (empty if valid) + */ + public static List validate(MongoOperation operation, String entityId) { + List errors = new ArrayList<>(); + + if (operation == null) { + errors.add(new ValidationError(entityId, MONGO_OPERATION, "Operation cannot be null")); + return errors; + } + + // Operation type + String typeValue = operation.getType(); + if (typeValue == null || typeValue.trim().isEmpty()) { + errors.add(new ValidationError(entityId, MONGO_OPERATION, "Operation type is required")); + return errors; + } + + MongoOperationType type; + try { + type = MongoOperationType.getFromValue(typeValue); + } catch (IllegalArgumentException e) { + errors.add(new ValidationError(entityId, MONGO_OPERATION, + "Unknown operation type: " + typeValue)); + return errors; // Can't continue with unknown type + } + + // 2. Collection name + errors.addAll(validateCollectionName(operation.getCollection(), entityId)); + + // 3. Type-specific + errors.addAll(validateByType(type, operation, entityId)); + + // 4. Rollback + if (operation.getRollback() != null) { + errors.addAll(validate(operation.getRollback(), entityId + ".rollback")); + } + + return errors; + } + + private static List validateCollectionName(String collection, String entityId) { + List errors = new ArrayList<>(); + + if (collection == null) { + errors.add(new ValidationError(entityId, MONGO_OPERATION, + "Collection name is required")); + } else if (collection.trim().isEmpty()) { + errors.add(new ValidationError(entityId, MONGO_OPERATION, + "Collection name cannot be empty")); + } else if (collection.contains("$")) { + errors.add(new ValidationError(entityId, MONGO_OPERATION, + "Collection name cannot contain '$': " + collection)); + } else if (collection.contains("\0")) { + errors.add(new ValidationError(entityId, MONGO_OPERATION, + "Collection name cannot contain null character")); + } + + return errors; + } + + private static List validateByType(MongoOperationType type, + MongoOperation op, + String entityId) { + switch (type) { + case INSERT: + return validateInsert(op, entityId); + case UPDATE: + return validateUpdate(op, entityId); + case DELETE: + return validateDelete(op, entityId); + case CREATE_INDEX: + return validateCreateIndex(op, entityId); + case DROP_INDEX: + return validateDropIndex(op, entityId); + case RENAME_COLLECTION: + return validateRenameCollection(op, entityId); + case CREATE_VIEW: + return validateCreateView(op, entityId); + default: + return new ArrayList<>(); + } + } + + private static List validateInsert(MongoOperation op, String entityId) { + List errors = new ArrayList<>(); + Map params = op.getParameters(); + + if (params == null) { + errors.add(new ValidationError(entityId, "InsertOperation", + "Insert operation requires 'parameters' with 'documents'")); + return errors; + } + + Object docs = params.get("documents"); + if (docs == null) { + errors.add(new ValidationError(entityId, "InsertOperation", + "Insert operation requires 'documents' parameter")); + return errors; + } + + if (!(docs instanceof List)) { + errors.add(new ValidationError(entityId, "InsertOperation", + "'documents' must be a list")); + return errors; + } + + List docList = (List) docs; + if (docList.isEmpty()) { + errors.add(new ValidationError(entityId, "InsertOperation", + "'documents' cannot be empty")); + } + + for (int i = 0; i < docList.size(); i++) { + if (docList.get(i) == null) { + errors.add(new ValidationError(entityId, "InsertOperation", + "Document at index " + i + " is null")); + } + } + + return errors; + } + + private static List validateUpdate(MongoOperation op, String entityId) { + List errors = new ArrayList<>(); + Map params = op.getParameters(); + + if (params == null) { + errors.add(new ValidationError(entityId, "UpdateOperation", + "Update operation requires 'parameters' with 'filter' and 'update'")); + return errors; + } + + if (!params.containsKey("filter")) { + errors.add(new ValidationError(entityId, "UpdateOperation", + "Update operation requires 'filter' parameter")); + } + + Object update = params.get("update"); + if (update == null) { + errors.add(new ValidationError(entityId, "UpdateOperation", + "Update operation requires 'update' parameter")); + } else if (!(update instanceof Map)) { + errors.add(new ValidationError(entityId, "UpdateOperation", + "'update' must be a document")); + } + + return errors; + } + + private static List validateDelete(MongoOperation op, String entityId) { + List errors = new ArrayList<>(); + Map params = op.getParameters(); + + if (params == null || !params.containsKey("filter")) { + errors.add(new ValidationError(entityId, "DeleteOperation", + "Delete operation requires 'filter' parameter")); + } + + return errors; + } + + private static List validateCreateIndex(MongoOperation op, String entityId) { + List errors = new ArrayList<>(); + Map params = op.getParameters(); + + if (params == null) { + errors.add(new ValidationError(entityId, "CreateIndexOperation", + "CreateIndex operation requires 'parameters' with 'keys'")); + return errors; + } + + Object keys = params.get("keys"); + if (keys == null) { + errors.add(new ValidationError(entityId, "CreateIndexOperation", + "CreateIndex operation requires 'keys' parameter")); + return errors; + } + + if (!(keys instanceof Map)) { + errors.add(new ValidationError(entityId, "CreateIndexOperation", + "'keys' must be a map")); + return errors; + } + + if (((Map) keys).isEmpty()) { + errors.add(new ValidationError(entityId, "CreateIndexOperation", + "'keys' cannot be empty")); + } + + return errors; + } + + private static List validateDropIndex(MongoOperation op, String entityId) { + List errors = new ArrayList<>(); + Map params = op.getParameters(); + + if (params == null) { + errors.add(new ValidationError(entityId, "DropIndexOperation", + "DropIndex operation requires 'parameters' with 'indexName' or 'keys'")); + return errors; + } + + Object indexName = params.get("indexName"); + Object keys = params.get("keys"); + + if (indexName == null && keys == null) { + errors.add(new ValidationError(entityId, "DropIndexOperation", + "DropIndex operation requires either 'indexName' or 'keys' parameter")); + } + + return errors; + } + + private static List validateRenameCollection(MongoOperation op, String entityId) { + List errors = new ArrayList<>(); + Map params = op.getParameters(); + + if (params == null || !params.containsKey("target")) { + errors.add(new ValidationError(entityId, "RenameCollectionOperation", + "RenameCollection operation requires 'target' parameter")); + return errors; + } + + Object target = params.get("target"); + if (target == null || (target instanceof String && ((String) target).trim().isEmpty())) { + errors.add(new ValidationError(entityId, "RenameCollectionOperation", + "'target' cannot be null or empty")); + } + + return errors; + } + + private static List validateCreateView(MongoOperation op, String entityId) { + List errors = new ArrayList<>(); + Map params = op.getParameters(); + + if (params == null) { + errors.add(new ValidationError(entityId, "CreateViewOperation", + "CreateView operation requires 'parameters' with 'viewOn' and 'pipeline'")); + return errors; + } + + Object viewOn = params.get("viewOn"); + if (viewOn == null || (viewOn instanceof String && ((String) viewOn).trim().isEmpty())) { + errors.add(new ValidationError(entityId, "CreateViewOperation", + "CreateView operation requires 'viewOn' parameter")); + } + + Object pipeline = params.get("pipeline"); + if (pipeline == null) { + errors.add(new ValidationError(entityId, "CreateViewOperation", + "CreateView operation requires 'pipeline' parameter")); + } else if (!(pipeline instanceof List)) { + errors.add(new ValidationError(entityId, "CreateViewOperation", + "'pipeline' must be a list")); + } + + return errors; + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoTemplateValidationException.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoTemplateValidationException.java new file mode 100644 index 000000000..8dd04ff34 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoTemplateValidationException.java @@ -0,0 +1,60 @@ +/* + * 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.validation; + +import java.util.Collections; +import java.util.List; + +/** + * Exception thrown when MongoDB template validation fails. + * + *

This exception collects all validation errors found during validation + * and provides a formatted message listing all issues.

+ */ +public class MongoTemplateValidationException extends RuntimeException { + + private final List errors; + + /** + * Creates a new validation exception with the given errors. + * + * @param errors the list of validation errors + */ + public MongoTemplateValidationException(List errors) { + super(formatMessage(errors)); + this.errors = Collections.unmodifiableList(errors); + } + + /** + * Returns the list of validation errors. + * + * @return unmodifiable list of validation errors + */ + public List getErrors() { + return errors; + } + + private static String formatMessage(List errors) { + StringBuilder sb = new StringBuilder("MongoDB template validation failed with ") + .append(errors.size()) + .append(" error(s):\n"); + for (ValidationError error : errors) { + sb.append(" - [").append(error.getEntityId()) + .append("] ").append(error.getMessage()).append("\n"); + } + return sb.toString(); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/ValidationError.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/ValidationError.java new file mode 100644 index 000000000..a04a3b394 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/ValidationError.java @@ -0,0 +1,59 @@ +/* + * 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.validation; + +/** + * Represents a validation error for MongoDB template operations. + * + *

Each error contains context about where the validation failed + * (entityId, entityType) and a human-readable error message.

+ */ +public class ValidationError { + + private final String entityId; + private final String entityType; + private final String message; + + /** + * Creates a new validation error. + * + * @param entityId the identifier of the entity that failed validation (e.g., "changeId.operations[0]") + * @param entityType the type of entity (e.g., "MongoOperation", "InsertOperation") + * @param message the human-readable error message + */ + public ValidationError(String entityId, String entityType, String message) { + this.entityId = entityId; + this.entityType = entityType; + this.message = message; + } + + public String getEntityId() { + return entityId; + } + + public String getEntityType() { + return entityType; + } + + public String getMessage() { + return message; + } + + @Override + public String toString() { + return String.format("[%s] %s: %s", entityId, entityType, message); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/AutoRollbackTemplateTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/AutoRollbackTemplateTest.java new file mode 100644 index 000000000..d0e4b2e2b --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/AutoRollbackTemplateTest.java @@ -0,0 +1,145 @@ +/* + * 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; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoApplyPayload; +import io.flamingock.template.mongodb.model.MongoOperation; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Template-level test for auto-rollback behavior in MongoChangeTemplate. + * + *

This test verifies that when an operation fails during template execution, + * the auto-rollback mechanism correctly rolls back previously successful + * operations in reverse order.

+ * + */ +@Testcontainers +class AutoRollbackTemplateTest { + + private static final String DB_NAME = "test"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection("auto_rollback_test").drop(); + } + + @Test + @DisplayName("WHEN operation fails during template execution THEN auto-rollback is triggered for previous operations") + void autoRollbackOnFailureTest() { + assertFalse(collectionExists("auto_rollback_test"), "Collection should not exist before test"); + + MongoApplyPayload payload = new MongoApplyPayload(); + List ops = new ArrayList<>(); + + MongoOperation op1 = new MongoOperation(); + op1.setType("createCollection"); + op1.setCollection("auto_rollback_test"); + op1.setParameters(new HashMap<>()); + MongoOperation rollback1 = new MongoOperation(); + rollback1.setType("dropCollection"); + rollback1.setCollection("auto_rollback_test"); + rollback1.setParameters(new HashMap<>()); + op1.setRollback(rollback1); + ops.add(op1); + + MongoOperation op2 = new MongoOperation(); + op2.setType("insert"); + op2.setCollection("auto_rollback_test"); + Map insertParams = new HashMap<>(); + List> docs = new ArrayList<>(); + Map doc1 = new HashMap<>(); + doc1.put("name", "Test User 1"); + docs.add(doc1); + Map doc2 = new HashMap<>(); + doc2.put("name", "Test User 2"); + docs.add(doc2); + insertParams.put("documents", docs); + op2.setParameters(insertParams); + MongoOperation rollback2 = new MongoOperation(); + rollback2.setType("delete"); + rollback2.setCollection("auto_rollback_test"); + Map deleteParams = new HashMap<>(); + deleteParams.put("filter", new HashMap<>()); + rollback2.setParameters(deleteParams); + op2.setRollback(rollback2); + ops.add(op2); + + MongoOperation op3 = new MongoOperation(); + op3.setType("createCollection"); + op3.setCollection("auto_rollback_test"); + op3.setParameters(new HashMap<>()); + ops.add(op3); + + payload.setOperations(ops); + + MongoChangeTemplate template = new MongoChangeTemplate(); + template.setApplyPayload(payload); + + Exception thrown = assertThrows(Exception.class, () -> { + template.apply(mongoDatabase, null); + }); + + assertTrue(thrown.getMessage().contains("already exists") || + thrown.getCause().getMessage().contains("already exists"), + "Should fail with 'already exists' error"); + + assertFalse(collectionExists("auto_rollback_test"), + "Collection should not exist after auto-rollback (dropCollection rollback executed)"); + } + + private boolean collectionExists(String collectionName) { + return mongoDatabase.listCollectionNames() + .into(new ArrayList<>()) + .contains(collectionName); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java index e46431f3c..9fa5cdff6 100644 --- a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java @@ -40,6 +40,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.assertTrue; @EnableFlamingock(configFile = "flamingock/pipeline.yaml") @Testcontainers @@ -68,7 +69,9 @@ static void beforeAll() { @BeforeEach void setupEach() { mongoDatabase.getCollection(DEFAULT_AUDIT_STORE_NAME).drop(); - mongoDatabase.getCollection(DEFAULT_AUDIT_STORE_NAME).drop(); + mongoDatabase.getCollection("users").drop(); + mongoDatabase.getCollection("products").drop(); + mongoDatabase.getCollection("orders").drop(); } @@ -88,7 +91,7 @@ void happyPath() { .find() .into(new ArrayList<>()); - assertEquals(4, auditLog.size()); + assertEquals(8, auditLog.size()); assertEquals("create-users-collection-with-index", auditLog.get(0).getString("changeId")); assertEquals(AuditEntry.Status.STARTED.name(), auditLog.get(0).getString("state")); @@ -100,6 +103,17 @@ void happyPath() { assertEquals("seed-users", auditLog.get(3).getString("changeId")); assertEquals(AuditEntry.Status.APPLIED.name(), auditLog.get(3).getString("state")); + assertEquals("multiple-operations-change", auditLog.get(4).getString("changeId")); + assertEquals(AuditEntry.Status.STARTED.name(), auditLog.get(4).getString("state")); + assertEquals("multiple-operations-change", auditLog.get(5).getString("changeId")); + assertEquals(AuditEntry.Status.APPLIED.name(), auditLog.get(5).getString("state")); + + assertEquals("per-operation-rollback-change", auditLog.get(6).getString("changeId")); + assertEquals(AuditEntry.Status.STARTED.name(), auditLog.get(6).getString("state")); + assertEquals("per-operation-rollback-change", auditLog.get(7).getString("changeId")); + assertEquals(AuditEntry.Status.APPLIED.name(), auditLog.get(7).getString("state")); + + // Verify for single operation List users = mongoDatabase.getCollection("users") .find() .into(new ArrayList<>()); @@ -112,7 +126,40 @@ void happyPath() { assertEquals("Backup", users.get(1).getString("name")); assertEquals("backup@company.com", users.get(1).getString("email")); assertEquals("readonly", users.get(1).getList("roles", String.class).get(0)); - } + // Verify for multiple operation + List products = mongoDatabase.getCollection("products") + .find() + .into(new ArrayList<>()); + + assertEquals(3, products.size(), "Should have 3 products from multiple operations"); + assertEquals("Laptop", products.get(0).getString("name")); + assertEquals("Keyboard", products.get(1).getString("name")); + assertEquals("Mouse", products.get(2).getString("name")); + + List indexes = mongoDatabase.getCollection("products") + .listIndexes() + .into(new ArrayList<>()); + boolean categoryIndexExists = indexes.stream() + .anyMatch(idx -> "category_index".equals(idx.getString("name"))); + assertTrue(categoryIndexExists, "Category index should exist on products collection"); + + List orders = mongoDatabase.getCollection("orders") + .find() + .into(new ArrayList<>()); + + assertEquals(2, orders.size(), "Should have 2 orders from per-operation rollback change"); + assertEquals("ORD-001", orders.get(0).getString("orderId")); + assertEquals("John Doe", orders.get(0).getString("customer")); + assertEquals("ORD-002", orders.get(1).getString("orderId")); + assertEquals("Jane Smith", orders.get(1).getString("customer")); + + List orderIndexes = mongoDatabase.getCollection("orders") + .listIndexes() + .into(new ArrayList<>()); + boolean orderIdIndexExists = orderIndexes.stream() + .anyMatch(idx -> "orderId_index".equals(idx.getString("name"))); + assertTrue(orderIdIndexExists, "orderId_index should exist on orders collection"); + } } diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/changes/_0003__multiple_operations.yaml b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/changes/_0003__multiple_operations.yaml new file mode 100644 index 000000000..e1627b83e --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/changes/_0003__multiple_operations.yaml @@ -0,0 +1,31 @@ +id: multiple-operations-change +transactional: false +template: MongoChangeTemplate +targetSystem: + id: "mongodb" +apply: + operations: + - 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" + + - type: createIndex + collection: products + parameters: + keys: + category: 1 + options: + name: "category_index" diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/changes/_0004__per_operation_rollback.yaml b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/changes/_0004__per_operation_rollback.yaml new file mode 100644 index 000000000..c68f446ba --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/changes/_0004__per_operation_rollback.yaml @@ -0,0 +1,42 @@ +id: per-operation-rollback-change +transactional: false +template: MongoChangeTemplate +targetSystem: + id: "mongodb" +apply: + operations: + - type: createCollection + collection: orders + rollback: + type: dropCollection + collection: orders + + - type: insert + collection: orders + parameters: + documents: + - orderId: "ORD-001" + customer: "John Doe" + total: 150.00 + - orderId: "ORD-002" + customer: "Jane Smith" + total: 250.00 + rollback: + type: delete + collection: orders + parameters: + filter: {} + + - 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/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/CreateViewOptionsMapperTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/CreateViewOptionsMapperTest.java new file mode 100644 index 000000000..562118c79 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/CreateViewOptionsMapperTest.java @@ -0,0 +1,67 @@ +/* + * 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.mapper; + +import com.mongodb.client.model.Collation; +import com.mongodb.client.model.CollationStrength; +import com.mongodb.client.model.CreateViewOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class CreateViewOptionsMapperTest { + + @Test + @DisplayName("WHEN options is empty THEN returns default CreateViewOptions") + void emptyOptionsTest() { + Map options = new HashMap<>(); + + CreateViewOptions result = CreateViewOptionsMapper.map(options); + + assertNotNull(result); + } + + @Test + @DisplayName("WHEN collation is set THEN option is set") + void collationTest() { + Collation collation = Collation.builder() + .locale("en") + .collationStrength(CollationStrength.PRIMARY) + .build(); + + Map options = new HashMap<>(); + options.put("collation", collation); + + CreateViewOptions result = CreateViewOptionsMapper.map(options); + + assertNotNull(result.getCollation()); + assertEquals("en", result.getCollation().getLocale()); + } + + @Test + @DisplayName("WHEN collation is wrong type THEN throws exception") + void collationWrongTypeTest() { + Map options = new HashMap<>(); + options.put("collation", "not a collation"); + + assertThrows(IllegalArgumentException.class, () -> + CreateViewOptionsMapper.map(options)); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/IndexOptionsMapperTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/IndexOptionsMapperTest.java new file mode 100644 index 000000000..831fc268d --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/IndexOptionsMapperTest.java @@ -0,0 +1,322 @@ +/* + * 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.mapper; + +import com.mongodb.client.model.Collation; +import com.mongodb.client.model.CollationStrength; +import com.mongodb.client.model.IndexOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +class IndexOptionsMapperTest { + + @Test + @DisplayName("WHEN options is empty THEN returns default IndexOptions") + void emptyOptionsTest() { + Map options = new HashMap<>(); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertNotNull(result); + } + + @Nested + @DisplayName("Boolean Options Tests") + class BooleanOptionsTests { + + @Test + @DisplayName("WHEN background is true THEN option is set") + void backgroundTrueTest() { + Map options = new HashMap<>(); + options.put("background", true); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertTrue(result.isBackground()); + } + + @Test + @DisplayName("WHEN unique is true THEN option is set") + void uniqueTrueTest() { + Map options = new HashMap<>(); + options.put("unique", true); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertTrue(result.isUnique()); + } + + @Test + @DisplayName("WHEN sparse is true THEN option is set") + void sparseTrueTest() { + Map options = new HashMap<>(); + options.put("sparse", true); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertTrue(result.isSparse()); + } + } + + @Nested + @DisplayName("String Options Tests") + class StringOptionsTests { + + @Test + @DisplayName("WHEN name is set THEN option is set") + void nameTest() { + Map options = new HashMap<>(); + options.put("name", "my_custom_index"); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals("my_custom_index", result.getName()); + } + + @Test + @DisplayName("WHEN defaultLanguage is set THEN option is set") + void defaultLanguageTest() { + Map options = new HashMap<>(); + options.put("defaultLanguage", "spanish"); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals("spanish", result.getDefaultLanguage()); + } + + @Test + @DisplayName("WHEN languageOverride is set THEN option is set") + void languageOverrideTest() { + Map options = new HashMap<>(); + options.put("languageOverride", "idioma"); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals("idioma", result.getLanguageOverride()); + } + } + + @Nested + @DisplayName("Numeric Options Tests") + class NumericOptionsTests { + + @Test + @DisplayName("WHEN expireAfterSeconds is set THEN option is set") + void expireAfterSecondsTest() { + Map options = new HashMap<>(); + options.put("expireAfterSeconds", 3600L); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals(3600L, result.getExpireAfter(TimeUnit.SECONDS)); + } + + @Test + @DisplayName("WHEN version is set THEN option is set") + void versionTest() { + Map options = new HashMap<>(); + options.put("version", 2); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals(2, result.getVersion()); + } + + @Test + @DisplayName("WHEN textVersion is set THEN option is set") + void textVersionTest() { + Map options = new HashMap<>(); + options.put("textVersion", 3); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals(3, result.getTextVersion()); + } + + @Test + @DisplayName("WHEN sphereVersion is set THEN option is set") + void sphereVersionTest() { + Map options = new HashMap<>(); + options.put("sphereVersion", 2); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals(2, result.getSphereVersion()); + } + + @Test + @DisplayName("WHEN bits is set THEN option is set") + void bitsTest() { + Map options = new HashMap<>(); + options.put("bits", 26); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals(26, result.getBits()); + } + + @Test + @DisplayName("WHEN min is set THEN option is set") + void minTest() { + Map options = new HashMap<>(); + options.put("min", -180.0); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals(-180.0, result.getMin(), 0.001); + } + + @Test + @DisplayName("WHEN max is set THEN option is set") + void maxTest() { + Map options = new HashMap<>(); + options.put("max", 180.0); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertEquals(180.0, result.getMax(), 0.001); + } + } + + @Nested + @DisplayName("Bson Options Tests") + class BsonOptionsTests { + + @Test + @DisplayName("WHEN weights is set THEN option is set") + void weightsTest() { + Map weights = new HashMap<>(); + weights.put("title", 10); + weights.put("content", 5); + + Map options = new HashMap<>(); + options.put("weights", weights); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertNotNull(result.getWeights()); + } + + @Test + @DisplayName("WHEN storageEngine is set THEN option is set") + void storageEngineTest() { + Map storageEngine = new HashMap<>(); + storageEngine.put("wiredTiger", new HashMap<>()); + + Map options = new HashMap<>(); + options.put("storageEngine", storageEngine); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertNotNull(result.getStorageEngine()); + } + + @Test + @DisplayName("WHEN partialFilterExpression is set THEN option is set") + void partialFilterExpressionTest() { + Map filter = new HashMap<>(); + filter.put("status", "active"); + + Map options = new HashMap<>(); + options.put("partialFilterExpression", filter); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertNotNull(result.getPartialFilterExpression()); + } + } + + @Nested + @DisplayName("Collation Tests") + class CollationTests { + + @Test + @DisplayName("WHEN collation is set THEN option is set") + void collationTest() { + Collation collation = Collation.builder() + .locale("en") + .collationStrength(CollationStrength.SECONDARY) + .build(); + + Map options = new HashMap<>(); + options.put("collation", collation); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertNotNull(result.getCollation()); + assertEquals("en", result.getCollation().getLocale()); + } + } + + @Nested + @DisplayName("Unsupported Options Tests") + class UnsupportedOptionsTests { + + @Test + @DisplayName("WHEN bucketSize is set THEN throws UnsupportedOperationException") + void bucketSizeTest() { + Map options = new HashMap<>(); + options.put("bucketSize", 1.0); + + assertThrows(UnsupportedOperationException.class, () -> + IndexOptionsMapper.mapToIndexOptions(options)); + } + + @Test + @DisplayName("WHEN wildcardProjection is set THEN throws UnsupportedOperationException") + void wildcardProjectionTest() { + Map options = new HashMap<>(); + options.put("wildcardProjection", new HashMap<>()); + + assertThrows(UnsupportedOperationException.class, () -> + IndexOptionsMapper.mapToIndexOptions(options)); + } + + @Test + @DisplayName("WHEN hidden is set THEN throws UnsupportedOperationException") + void hiddenTest() { + Map options = new HashMap<>(); + options.put("hidden", true); + + assertThrows(UnsupportedOperationException.class, () -> + IndexOptionsMapper.mapToIndexOptions(options)); + } + } + + @Test + @DisplayName("WHEN multiple options are set THEN all are applied") + void multipleOptionsTest() { + Map options = new HashMap<>(); + options.put("unique", true); + options.put("sparse", true); + options.put("name", "compound_index"); + options.put("background", false); + + IndexOptions result = IndexOptionsMapper.mapToIndexOptions(options); + + assertTrue(result.isUnique()); + assertTrue(result.isSparse()); + assertEquals("compound_index", result.getName()); + assertFalse(result.isBackground()); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/InsertOptionsMapperTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/InsertOptionsMapperTest.java new file mode 100644 index 000000000..73daf67e9 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/InsertOptionsMapperTest.java @@ -0,0 +1,166 @@ +/* + * 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.mapper; + +import com.mongodb.client.model.InsertManyOptions; +import com.mongodb.client.model.InsertOneOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class InsertOptionsMapperTest { + + @Nested + @DisplayName("mapToInsertOneOptions Tests") + class MapToInsertOneOptionsTests { + + @Test + @DisplayName("WHEN options is empty THEN returns default InsertOneOptions") + void emptyOptionsTest() { + Map options = new HashMap<>(); + + InsertOneOptions result = InsertOptionsMapper.mapToInsertOneOptions(options); + + assertNotNull(result); + } + + @Test + @DisplayName("WHEN bypassDocumentValidation is true THEN option is set") + void bypassDocumentValidationTrueTest() { + Map options = new HashMap<>(); + options.put("bypassDocumentValidation", true); + + InsertOneOptions result = InsertOptionsMapper.mapToInsertOneOptions(options); + + assertNotNull(result); + assertEquals(Boolean.TRUE, result.getBypassDocumentValidation()); + } + + @Test + @DisplayName("WHEN bypassDocumentValidation is false THEN option is set") + void bypassDocumentValidationFalseTest() { + Map options = new HashMap<>(); + options.put("bypassDocumentValidation", false); + + InsertOneOptions result = InsertOptionsMapper.mapToInsertOneOptions(options); + + assertNotNull(result); + assertNotEquals(Boolean.TRUE, result.getBypassDocumentValidation()); + } + + @Test + @DisplayName("WHEN bypassDocumentValidation is wrong type THEN throws exception") + void bypassDocumentValidationWrongTypeTest() { + Map options = new HashMap<>(); + options.put("bypassDocumentValidation", "not a boolean"); + + assertThrows(IllegalArgumentException.class, () -> + InsertOptionsMapper.mapToInsertOneOptions(options)); + } + } + + @Nested + @DisplayName("mapToInsertManyOptions Tests") + class MapToInsertManyOptionsTests { + + @Test + @DisplayName("WHEN options is empty THEN returns default InsertManyOptions") + void emptyOptionsTest() { + Map options = new HashMap<>(); + + InsertManyOptions result = InsertOptionsMapper.mapToInsertManyOptions(options); + + assertNotNull(result); + } + + @Test + @DisplayName("WHEN bypassDocumentValidation is true THEN option is set") + void bypassDocumentValidationTrueTest() { + Map options = new HashMap<>(); + options.put("bypassDocumentValidation", true); + + InsertManyOptions result = InsertOptionsMapper.mapToInsertManyOptions(options); + + assertNotNull(result); + assertEquals(Boolean.TRUE, result.getBypassDocumentValidation()); + } + + @Test + @DisplayName("WHEN bypassDocumentValidation is false THEN option is set") + void bypassDocumentValidationFalseTest() { + Map options = new HashMap<>(); + options.put("bypassDocumentValidation", false); + + InsertManyOptions result = InsertOptionsMapper.mapToInsertManyOptions(options); + + assertNotNull(result); + assertNotEquals(Boolean.TRUE, result.getBypassDocumentValidation()); + } + + @Test + @DisplayName("WHEN ordered is true THEN option is set") + void orderedTrueTest() { + Map options = new HashMap<>(); + options.put("ordered", true); + + InsertManyOptions result = InsertOptionsMapper.mapToInsertManyOptions(options); + + assertNotNull(result); + assertTrue(result.isOrdered()); + } + + @Test + @DisplayName("WHEN ordered is false THEN option is set") + void orderedFalseTest() { + Map options = new HashMap<>(); + options.put("ordered", false); + + InsertManyOptions result = InsertOptionsMapper.mapToInsertManyOptions(options); + + assertNotNull(result); + assertFalse(result.isOrdered()); + } + + @Test + @DisplayName("WHEN both options are set THEN both are applied") + void bothOptionsTest() { + Map options = new HashMap<>(); + options.put("bypassDocumentValidation", true); + options.put("ordered", false); + + InsertManyOptions result = InsertOptionsMapper.mapToInsertManyOptions(options); + + assertNotNull(result); + assertEquals(Boolean.TRUE, result.getBypassDocumentValidation()); + assertFalse(result.isOrdered()); + } + + @Test + @DisplayName("WHEN ordered is wrong type THEN throws exception") + void orderedWrongTypeTest() { + Map options = new HashMap<>(); + options.put("ordered", "not a boolean"); + + assertThrows(IllegalArgumentException.class, () -> + InsertOptionsMapper.mapToInsertManyOptions(options)); + } + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/MapperUtilTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/MapperUtilTest.java new file mode 100644 index 000000000..0d31b7541 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/MapperUtilTest.java @@ -0,0 +1,452 @@ +/* + * 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.mapper; + +import com.mongodb.client.model.Collation; +import com.mongodb.client.model.CollationStrength; +import org.bson.BsonArray; +import org.bson.BsonBoolean; +import org.bson.BsonDocument; +import org.bson.BsonDouble; +import org.bson.BsonInt32; +import org.bson.BsonInt64; +import org.bson.BsonNull; +import org.bson.BsonString; +import org.bson.BsonValue; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; + +class MapperUtilTest { + + @Nested + @DisplayName("getBoolean Tests") + class GetBooleanTests { + + @Test + @DisplayName("WHEN value is Boolean THEN returns value") + void getBooleanValidTest() { + Map options = new HashMap<>(); + options.put("flag", true); + + Boolean result = MapperUtil.getBoolean(options, "flag"); + + assertTrue(result); + } + + @Test + @DisplayName("WHEN value is not Boolean THEN throws IllegalArgumentException") + void getBooleanInvalidTest() { + Map options = new HashMap<>(); + options.put("flag", "not a boolean"); + + assertThrows(IllegalArgumentException.class, () -> + MapperUtil.getBoolean(options, "flag")); + } + } + + @Nested + @DisplayName("getString Tests") + class GetStringTests { + + @Test + @DisplayName("WHEN value is String THEN returns value") + void getStringValidTest() { + Map options = new HashMap<>(); + options.put("name", "testName"); + + String result = MapperUtil.getString(options, "name"); + + assertEquals("testName", result); + } + + @Test + @DisplayName("WHEN value is not String THEN throws IllegalArgumentException") + void getStringInvalidTest() { + Map options = new HashMap<>(); + options.put("name", 123); + + assertThrows(IllegalArgumentException.class, () -> + MapperUtil.getString(options, "name")); + } + } + + @Nested + @DisplayName("getInteger Tests") + class GetIntegerTests { + + @Test + @DisplayName("WHEN value is Integer THEN returns value") + void getIntegerValidTest() { + Map options = new HashMap<>(); + options.put("count", 42); + + Integer result = MapperUtil.getInteger(options, "count"); + + assertEquals(42, result); + } + + @Test + @DisplayName("WHEN value is Long THEN returns as Integer") + void getIntegerFromLongTest() { + Map options = new HashMap<>(); + options.put("count", 42L); + + Integer result = MapperUtil.getInteger(options, "count"); + + assertEquals(42, result); + } + + @Test + @DisplayName("WHEN value is not Number THEN throws IllegalArgumentException") + void getIntegerInvalidTest() { + Map options = new HashMap<>(); + options.put("count", "not a number"); + + assertThrows(IllegalArgumentException.class, () -> + MapperUtil.getInteger(options, "count")); + } + } + + @Nested + @DisplayName("getLong Tests") + class GetLongTests { + + @Test + @DisplayName("WHEN value is Long THEN returns value") + void getLongValidTest() { + Map options = new HashMap<>(); + options.put("timestamp", 1234567890L); + + Long result = MapperUtil.getLong(options, "timestamp"); + + assertEquals(1234567890L, result); + } + + @Test + @DisplayName("WHEN value is Integer THEN returns as Long") + void getLongFromIntegerTest() { + Map options = new HashMap<>(); + options.put("timestamp", 42); + + Long result = MapperUtil.getLong(options, "timestamp"); + + assertEquals(42L, result); + } + + @Test + @DisplayName("WHEN value is not Number THEN throws IllegalArgumentException") + void getLongInvalidTest() { + Map options = new HashMap<>(); + options.put("timestamp", "not a number"); + + assertThrows(IllegalArgumentException.class, () -> + MapperUtil.getLong(options, "timestamp")); + } + } + + @Nested + @DisplayName("getDouble Tests") + class GetDoubleTests { + + @Test + @DisplayName("WHEN value is Double THEN returns value") + void getDoubleValidTest() { + Map options = new HashMap<>(); + options.put("rate", 3.14); + + Double result = MapperUtil.getDouble(options, "rate"); + + assertEquals(3.14, result, 0.001); + } + + @Test + @DisplayName("WHEN value is Integer THEN returns as Double") + void getDoubleFromIntegerTest() { + Map options = new HashMap<>(); + options.put("rate", 42); + + Double result = MapperUtil.getDouble(options, "rate"); + + assertEquals(42.0, result, 0.001); + } + + @Test + @DisplayName("WHEN value is not Number THEN throws IllegalArgumentException") + void getDoubleInvalidTest() { + Map options = new HashMap<>(); + options.put("rate", "not a number"); + + assertThrows(IllegalArgumentException.class, () -> + MapperUtil.getDouble(options, "rate")); + } + } + + @Nested + @DisplayName("getBson Tests") + class GetBsonTests { + + @Test + @DisplayName("WHEN value is Map THEN returns BsonDocument") + void getBsonFromMapTest() { + Map innerMap = new HashMap<>(); + innerMap.put("field", "value"); + + Map options = new HashMap<>(); + options.put("doc", innerMap); + + org.bson.conversions.Bson result = MapperUtil.getBson(options, "doc"); + + assertNotNull(result); + assertInstanceOf(BsonDocument.class, result); + } + + @Test + @DisplayName("WHEN value is not Bson or Map THEN throws IllegalArgumentException") + void getBsonInvalidTest() { + Map options = new HashMap<>(); + options.put("doc", "not a bson"); + + assertThrows(IllegalArgumentException.class, () -> + MapperUtil.getBson(options, "doc")); + } + } + + @Nested + @DisplayName("getCollation Tests") + class GetCollationTests { + + @Test + @DisplayName("WHEN value is Collation THEN returns value") + void getCollationValidTest() { + Collation collation = Collation.builder() + .locale("en") + .collationStrength(CollationStrength.PRIMARY) + .build(); + + Map options = new HashMap<>(); + options.put("collation", collation); + + Collation result = MapperUtil.getCollation(options, "collation"); + + assertEquals(collation, result); + } + + @Test + @DisplayName("WHEN value is not Collation THEN throws IllegalArgumentException") + void getCollationInvalidTest() { + Map options = new HashMap<>(); + options.put("collation", "not a collation"); + + assertThrows(IllegalArgumentException.class, () -> + MapperUtil.getCollation(options, "collation")); + } + } + + @Nested + @DisplayName("toBsonValue Tests") + class ToBsonValueTests { + + @Test + @DisplayName("WHEN value is null THEN returns BsonNull") + void toBsonValueNullTest() { + BsonValue result = MapperUtil.toBsonValue(null); + + assertEquals(BsonNull.VALUE, result); + } + + @Test + @DisplayName("WHEN value is String THEN returns BsonString") + void toBsonValueStringTest() { + BsonValue result = MapperUtil.toBsonValue("test"); + + assertInstanceOf(BsonString.class, result); + assertEquals("test", ((BsonString) result).getValue()); + } + + @Test + @DisplayName("WHEN value is Integer THEN returns BsonInt32") + void toBsonValueIntegerTest() { + BsonValue result = MapperUtil.toBsonValue(42); + + assertInstanceOf(BsonInt32.class, result); + assertEquals(42, ((BsonInt32) result).getValue()); + } + + @Test + @DisplayName("WHEN value is Long THEN returns BsonInt64") + void toBsonValueLongTest() { + BsonValue result = MapperUtil.toBsonValue(42L); + + assertInstanceOf(BsonInt64.class, result); + assertEquals(42L, ((BsonInt64) result).getValue()); + } + + @Test + @DisplayName("WHEN value is Double THEN returns BsonDouble") + void toBsonValueDoubleTest() { + BsonValue result = MapperUtil.toBsonValue(3.14); + + assertInstanceOf(BsonDouble.class, result); + assertEquals(3.14, ((BsonDouble) result).getValue(), 0.001); + } + + @Test + @DisplayName("WHEN value is Boolean THEN returns BsonBoolean") + void toBsonValueBooleanTest() { + BsonValue result = MapperUtil.toBsonValue(true); + + assertInstanceOf(BsonBoolean.class, result); + assertTrue(((BsonBoolean) result).getValue()); + } + + @Test + @DisplayName("WHEN value is Map THEN returns BsonDocument") + void toBsonValueMapTest() { + Map map = new HashMap<>(); + map.put("name", "test"); + map.put("count", 42); + + BsonValue result = MapperUtil.toBsonValue(map); + + assertInstanceOf(BsonDocument.class, result); + BsonDocument doc = (BsonDocument) result; + assertEquals("test", doc.getString("name").getValue()); + assertEquals(42, doc.getInt32("count").getValue()); + } + + @Test + @DisplayName("WHEN value is List THEN returns BsonArray") + void toBsonValueListTest() { + List list = Arrays.asList("item1", 42, true); + + BsonValue result = MapperUtil.toBsonValue(list); + + assertInstanceOf(BsonArray.class, result); + BsonArray array = (BsonArray) result; + assertEquals(3, array.size()); + assertEquals("item1", array.get(0).asString().getValue()); + assertEquals(42, array.get(1).asInt32().getValue()); + assertTrue(array.get(2).asBoolean().getValue()); + } + + @Test + @DisplayName("WHEN value is unsupported type THEN throws IllegalArgumentException") + void toBsonValueUnsupportedTest() { + assertThrows(IllegalArgumentException.class, () -> + MapperUtil.toBsonValue(new Object())); + } + } + + @Nested + @DisplayName("toBsonArray Tests") + class ToBsonArrayTests { + + @Test + @DisplayName("WHEN list has mixed types THEN returns BsonArray with correct types") + void toBsonArrayMixedTypesTest() { + List list = Arrays.asList("text", 123, 456L, 3.14, false, null); + + BsonArray result = MapperUtil.toBsonArray(list); + + assertEquals(6, result.size()); + assertInstanceOf(BsonString.class, result.get(0)); + assertInstanceOf(BsonInt32.class, result.get(1)); + assertInstanceOf(BsonInt64.class, result.get(2)); + assertInstanceOf(BsonDouble.class, result.get(3)); + assertInstanceOf(BsonBoolean.class, result.get(4)); + assertTrue(result.get(5).isNull()); + } + + @Test + @DisplayName("WHEN list has nested list THEN returns nested BsonArray") + void toBsonArrayNestedTest() { + List innerList = Arrays.asList(1, 2, 3); + List outerList = Arrays.asList("outer", innerList); + + BsonArray result = MapperUtil.toBsonArray(outerList); + + assertEquals(2, result.size()); + assertEquals("outer", result.get(0).asString().getValue()); + assertInstanceOf(BsonArray.class, result.get(1)); + BsonArray nested = result.get(1).asArray(); + assertEquals(3, nested.size()); + } + + @Test + @DisplayName("WHEN list has map THEN returns BsonArray with BsonDocument") + void toBsonArrayWithMapTest() { + Map map = new HashMap<>(); + map.put("key", "value"); + List list = Collections.singletonList(map); + + BsonArray result = MapperUtil.toBsonArray(list); + + assertEquals(1, result.size()); + assertInstanceOf(BsonDocument.class, result.get(0)); + assertEquals("value", result.get(0).asDocument().getString("key").getValue()); + } + + @Test + @DisplayName("WHEN list is empty THEN returns empty BsonArray") + void toBsonArrayEmptyTest() { + List list = Collections.emptyList(); + + BsonArray result = MapperUtil.toBsonArray(list); + + assertTrue(result.isEmpty()); + } + } + + @Nested + @DisplayName("toBsonDocument Tests") + class ToBsonDocumentTests { + + @Test + @DisplayName("WHEN map has nested structure THEN returns nested BsonDocument") + void toBsonDocumentNestedTest() { + Map inner = new HashMap<>(); + inner.put("field", "value"); + + Map outer = new HashMap<>(); + outer.put("nested", inner); + outer.put("simple", "text"); + + BsonDocument result = MapperUtil.toBsonDocument(outer); + + assertEquals("text", result.getString("simple").getValue()); + assertInstanceOf(BsonDocument.class, result.get("nested")); + assertEquals("value", result.getDocument("nested").getString("field").getValue()); + } + + @Test + @DisplayName("WHEN map has null value THEN returns BsonNull in document") + void toBsonDocumentWithNullTest() { + Map map = new HashMap<>(); + map.put("nullField", null); + map.put("stringField", "value"); + + BsonDocument result = MapperUtil.toBsonDocument(map); + + assertTrue(result.get("nullField").isNull()); + assertEquals("value", result.getString("stringField").getValue()); + } + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/RenameCollectionOptionsMapperTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/RenameCollectionOptionsMapperTest.java new file mode 100644 index 000000000..c6115ffca --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/RenameCollectionOptionsMapperTest.java @@ -0,0 +1,70 @@ +/* + * 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.mapper; + +import com.mongodb.client.model.RenameCollectionOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class RenameCollectionOptionsMapperTest { + + @Test + @DisplayName("WHEN options is empty THEN returns default RenameCollectionOptions") + void emptyOptionsTest() { + Map options = new HashMap<>(); + + RenameCollectionOptions result = RenameCollectionOptionsMapper.map(options); + + assertNotNull(result); + } + + @Test + @DisplayName("WHEN dropTarget is true THEN option is set") + void dropTargetTrueTest() { + Map options = new HashMap<>(); + options.put("dropTarget", true); + + RenameCollectionOptions result = RenameCollectionOptionsMapper.map(options); + + assertTrue(result.isDropTarget()); + } + + @Test + @DisplayName("WHEN dropTarget is false THEN option is set") + void dropTargetFalseTest() { + Map options = new HashMap<>(); + options.put("dropTarget", false); + + RenameCollectionOptions result = RenameCollectionOptionsMapper.map(options); + + assertFalse(result.isDropTarget()); + } + + @Test + @DisplayName("WHEN dropTarget is wrong type THEN throws exception") + void dropTargetWrongTypeTest() { + Map options = new HashMap<>(); + options.put("dropTarget", "not a boolean"); + + assertThrows(IllegalArgumentException.class, () -> + RenameCollectionOptionsMapper.map(options)); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/UpdateOptionsMapperTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/UpdateOptionsMapperTest.java new file mode 100644 index 000000000..c848ab88f --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/UpdateOptionsMapperTest.java @@ -0,0 +1,145 @@ +/* + * 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.mapper; + +import com.mongodb.client.model.Collation; +import com.mongodb.client.model.CollationStrength; +import com.mongodb.client.model.UpdateOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class UpdateOptionsMapperTest { + + @Nested + @DisplayName("mapToUpdateOptions Tests") + class MapToUpdateOptionsTests { + + @Test + @DisplayName("WHEN options is empty THEN returns default UpdateOptions") + void emptyOptionsTest() { + Map options = new HashMap<>(); + + UpdateOptions result = UpdateOptionsMapper.mapToUpdateOptions(options); + + assertNotNull(result); + assertFalse(result.isUpsert()); + } + + @Test + @DisplayName("WHEN upsert is true THEN option is set") + void upsertTrueTest() { + Map options = new HashMap<>(); + options.put("upsert", true); + + UpdateOptions result = UpdateOptionsMapper.mapToUpdateOptions(options); + + assertNotNull(result); + assertTrue(result.isUpsert()); + } + + @Test + @DisplayName("WHEN upsert is false THEN option is set") + void upsertFalseTest() { + Map options = new HashMap<>(); + options.put("upsert", false); + + UpdateOptions result = UpdateOptionsMapper.mapToUpdateOptions(options); + + assertNotNull(result); + assertFalse(result.isUpsert()); + } + + @Test + @DisplayName("WHEN bypassDocumentValidation is true THEN option is set") + void bypassDocumentValidationTrueTest() { + Map options = new HashMap<>(); + options.put("bypassDocumentValidation", true); + + UpdateOptions result = UpdateOptionsMapper.mapToUpdateOptions(options); + + assertNotNull(result); + assertEquals(Boolean.TRUE, result.getBypassDocumentValidation()); + } + + @Test + @DisplayName("WHEN collation is set THEN option is applied") + void collationTest() { + Collation collation = Collation.builder() + .locale("en") + .collationStrength(CollationStrength.SECONDARY) + .build(); + + Map options = new HashMap<>(); + options.put("collation", collation); + + UpdateOptions result = UpdateOptionsMapper.mapToUpdateOptions(options); + + assertNotNull(result); + assertNotNull(result.getCollation()); + assertEquals("en", result.getCollation().getLocale()); + } + + @Test + @DisplayName("WHEN arrayFilters is set with maps THEN option is applied") + void arrayFiltersWithMapsTest() { + List> arrayFilters = new ArrayList<>(); + Map filter1 = new HashMap<>(); + filter1.put("elem.grade", "A"); + arrayFilters.add(filter1); + + Map options = new HashMap<>(); + options.put("arrayFilters", arrayFilters); + + UpdateOptions result = UpdateOptionsMapper.mapToUpdateOptions(options); + + assertNotNull(result); + assertNotNull(result.getArrayFilters()); + assertEquals(1, result.getArrayFilters().size()); + } + + @Test + @DisplayName("WHEN multiple options are set THEN all are applied") + void multipleOptionsTest() { + Map options = new HashMap<>(); + options.put("upsert", true); + options.put("bypassDocumentValidation", true); + + UpdateOptions result = UpdateOptionsMapper.mapToUpdateOptions(options); + + assertNotNull(result); + assertTrue(result.isUpsert()); + assertEquals(Boolean.TRUE, result.getBypassDocumentValidation()); + } + + @Test + @DisplayName("WHEN upsert is wrong type THEN throws exception") + void upsertWrongTypeTest() { + Map options = new HashMap<>(); + options.put("upsert", "not a boolean"); + + assertThrows(IllegalArgumentException.class, () -> + UpdateOptionsMapper.mapToUpdateOptions(options)); + } + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateCollectionOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateCollectionOperatorTest.java new file mode 100644 index 000000000..4aa050b11 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateCollectionOperatorTest.java @@ -0,0 +1,87 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.CreateCollectionOperator; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +class CreateCollectionOperatorTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "newTestCollection"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + } + + @Test + @DisplayName("WHEN createCollection operator is applied THEN collection is created") + void createCollectionTest() { + assertFalse(collectionExists(COLLECTION_NAME), "Collection should not exist before creation"); + + MongoOperation operation = new MongoOperation(); + operation.setType("createCollection"); + operation.setCollection(COLLECTION_NAME); + operation.setParameters(new HashMap<>()); + + CreateCollectionOperator operator = new CreateCollectionOperator(mongoDatabase, operation); + operator.apply(null); + + assertTrue(collectionExists(COLLECTION_NAME), "Collection should exist after creation"); + } + + private boolean collectionExists(String collectionName) { + return mongoDatabase.listCollectionNames() + .into(new ArrayList<>()) + .contains(collectionName); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateIndexOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateIndexOperatorTest.java new file mode 100644 index 000000000..a49ae801e --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateIndexOperatorTest.java @@ -0,0 +1,143 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.CreateIndexOperator; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +class CreateIndexOperatorTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "indexTestCollection"; + private static final String INDEX_NAME = "email_unique_index"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + } + + @Test + @DisplayName("WHEN createIndex operator is applied THEN index is created") + void createIndexTest() { + mongoDatabase.createCollection(COLLECTION_NAME); + + MongoOperation operation = new MongoOperation(); + operation.setType("createIndex"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map keys = new HashMap<>(); + keys.put("email", 1); + params.put("keys", keys); + operation.setParameters(params); + + CreateIndexOperator operator = new CreateIndexOperator(mongoDatabase, operation); + operator.apply(null); + + assertTrue(indexExistsByKeys("email"), "Index should exist after creation"); + } + + @Test + @DisplayName("WHEN createIndex operator is applied with options THEN index is created with options") + void createIndexWithOptionsTest() { + mongoDatabase.createCollection(COLLECTION_NAME); + + MongoOperation operation = new MongoOperation(); + operation.setType("createIndex"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map keys = new HashMap<>(); + keys.put("email", 1); + params.put("keys", keys); + + Map options = new HashMap<>(); + options.put("unique", true); + options.put("name", INDEX_NAME); + params.put("options", options); + + operation.setParameters(params); + + CreateIndexOperator operator = new CreateIndexOperator(mongoDatabase, operation); + operator.apply(null); + + assertTrue(indexExists(INDEX_NAME), "Index should exist with specified name"); + assertTrue(isIndexUnique(INDEX_NAME), "Index should be unique"); + } + + private boolean indexExists(String indexName) { + List indexes = mongoDatabase.getCollection(COLLECTION_NAME) + .listIndexes() + .into(new ArrayList<>()); + return indexes.stream().anyMatch(idx -> indexName.equals(idx.getString("name"))); + } + + private boolean indexExistsByKeys(String keyField) { + List indexes = mongoDatabase.getCollection(COLLECTION_NAME) + .listIndexes() + .into(new ArrayList<>()); + return indexes.stream().anyMatch(idx -> { + Document key = idx.get("key", Document.class); + return key != null && key.containsKey(keyField); + }); + } + + private boolean isIndexUnique(String indexName) { + List indexes = mongoDatabase.getCollection(COLLECTION_NAME) + .listIndexes() + .into(new ArrayList<>()); + return indexes.stream() + .filter(idx -> indexName.equals(idx.getString("name"))) + .anyMatch(idx -> Boolean.TRUE.equals(idx.getBoolean("unique"))); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateViewOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateViewOperatorTest.java new file mode 100644 index 000000000..b8b3330c4 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateViewOperatorTest.java @@ -0,0 +1,110 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.CreateViewOperator; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +class CreateViewOperatorTest { + + private static final String DB_NAME = "test"; + private static final String SOURCE_COLLECTION = "viewSourceCollection"; + private static final String VIEW_NAME = "activeUsersView"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(SOURCE_COLLECTION).drop(); + mongoDatabase.getCollection(VIEW_NAME).drop(); + } + + @Test + @DisplayName("WHEN createView operator is applied THEN view is created and filters data") + void createViewTest() { + mongoDatabase.createCollection(SOURCE_COLLECTION); + mongoDatabase.getCollection(SOURCE_COLLECTION).insertMany(Arrays.asList( + new Document("name", "Active User").append("status", "active"), + new Document("name", "Inactive User").append("status", "inactive") + )); + + MongoOperation operation = new MongoOperation(); + operation.setType("createView"); + operation.setCollection(VIEW_NAME); + + Map params = new HashMap<>(); + params.put("viewOn", SOURCE_COLLECTION); + + List> pipeline = new ArrayList<>(); + Map matchStage = new HashMap<>(); + Map matchQuery = new HashMap<>(); + matchQuery.put("status", "active"); + matchStage.put("$match", matchQuery); + pipeline.add(matchStage); + params.put("pipeline", pipeline); + + operation.setParameters(params); + + CreateViewOperator operator = new CreateViewOperator(mongoDatabase, operation); + operator.apply(null); + + List collections = mongoDatabase.listCollectionNames().into(new ArrayList<>()); + assertTrue(collections.contains(VIEW_NAME), "View should exist"); + + List viewResults = mongoDatabase.getCollection(VIEW_NAME) + .find() + .into(new ArrayList<>()); + assertEquals(1, viewResults.size(), "View should return only active users"); + assertEquals("Active User", viewResults.get(0).getString("name")); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DeleteOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DeleteOperatorTest.java new file mode 100644 index 000000000..c72cda2fa --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DeleteOperatorTest.java @@ -0,0 +1,174 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.DeleteOperator; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +@Testcontainers +class DeleteOperatorTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "deleteTestCollection"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + mongoDatabase.createCollection(COLLECTION_NAME); + List docs = new ArrayList<>(); + docs.add(new Document("name", "Alice").append("role", "admin")); + docs.add(new Document("name", "Bob").append("role", "user")); + docs.add(new Document("name", "Charlie").append("role", "user")); + docs.add(new Document("name", "Diana").append("role", "admin")); + mongoDatabase.getCollection(COLLECTION_NAME).insertMany(docs); + } + + @Test + @DisplayName("WHEN delete operator is applied with specific filter THEN matching documents are deleted") + void deleteWithFilterTest() { + assertEquals(4, getDocumentCount(), "Collection should have 4 documents before delete"); + + MongoOperation operation = new MongoOperation(); + operation.setType("delete"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("role", "user"); + params.put("filter", filter); + operation.setParameters(params); + + DeleteOperator operator = new DeleteOperator(mongoDatabase, operation); + operator.apply(null); + + assertEquals(2, getDocumentCount(), "Collection should have 2 documents after delete"); + + List remainingDocs = mongoDatabase.getCollection(COLLECTION_NAME) + .find() + .into(new ArrayList<>()); + + for (Document doc : remainingDocs) { + assertEquals("admin", doc.getString("role"), "Only admin documents should remain"); + } + } + + @Test + @DisplayName("WHEN delete operator is applied with empty filter THEN all documents are deleted") + void deleteAllWithEmptyFilterTest() { + assertEquals(4, getDocumentCount(), "Collection should have 4 documents before delete"); + + MongoOperation operation = new MongoOperation(); + operation.setType("delete"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + params.put("filter", new HashMap<>()); + operation.setParameters(params); + + DeleteOperator operator = new DeleteOperator(mongoDatabase, operation); + operator.apply(null); + + assertEquals(0, getDocumentCount(), "Collection should be empty after delete with empty filter"); + } + + @Test + @DisplayName("WHEN delete operator is applied with filter matching single document THEN only that document is deleted") + void deleteSingleDocumentTest() { + assertEquals(4, getDocumentCount(), "Collection should have 4 documents before delete"); + + MongoOperation operation = new MongoOperation(); + operation.setType("delete"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("name", "Alice"); + params.put("filter", filter); + operation.setParameters(params); + + DeleteOperator operator = new DeleteOperator(mongoDatabase, operation); + operator.apply(null); + + assertEquals(3, getDocumentCount(), "Collection should have 3 documents after delete"); + + Document alice = mongoDatabase.getCollection(COLLECTION_NAME) + .find(new Document("name", "Alice")) + .first(); + assertNull(alice, "Alice should be deleted"); + } + + @Test + @DisplayName("WHEN delete operator is applied with non-matching filter THEN no documents are deleted") + void deleteWithNonMatchingFilterTest() { + assertEquals(4, getDocumentCount(), "Collection should have 4 documents before delete"); + + MongoOperation operation = new MongoOperation(); + operation.setType("delete"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("name", "NonExistent"); + params.put("filter", filter); + operation.setParameters(params); + + DeleteOperator operator = new DeleteOperator(mongoDatabase, operation); + operator.apply(null); + + assertEquals(4, getDocumentCount(), "Collection should still have 4 documents"); + } + + private long getDocumentCount() { + return mongoDatabase.getCollection(COLLECTION_NAME).countDocuments(); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropCollectionOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropCollectionOperatorTest.java new file mode 100644 index 000000000..276f49e52 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropCollectionOperatorTest.java @@ -0,0 +1,88 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.DropCollectionOperator; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +class DropCollectionOperatorTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "testCollection"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + } + + @Test + @DisplayName("WHEN dropCollection operator is applied THEN collection is dropped") + void dropCollectionTest() { + mongoDatabase.createCollection(COLLECTION_NAME); + assertTrue(collectionExists(COLLECTION_NAME), "Collection should exist before drop"); + + MongoOperation operation = new MongoOperation(); + operation.setType("dropCollection"); + operation.setCollection(COLLECTION_NAME); + operation.setParameters(new HashMap<>()); + + DropCollectionOperator operator = new DropCollectionOperator(mongoDatabase, operation); + operator.apply(null); + + assertFalse(collectionExists(COLLECTION_NAME), "Collection should have been dropped"); + } + + private boolean collectionExists(String collectionName) { + return mongoDatabase.listCollectionNames() + .into(new ArrayList<>()) + .contains(collectionName); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropIndexOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropIndexOperatorTest.java new file mode 100644 index 000000000..fbb4a3342 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropIndexOperatorTest.java @@ -0,0 +1,129 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.DropIndexOperator; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +class DropIndexOperatorTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "indexTestCollection"; + private static final String INDEX_NAME = "email_index"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + } + + @Test + @DisplayName("WHEN dropIndex operator is applied with index name THEN index is dropped") + void dropIndexByNameTest() { + mongoDatabase.createCollection(COLLECTION_NAME); + mongoDatabase.getCollection(COLLECTION_NAME).createIndex(new Document("email", 1), + new com.mongodb.client.model.IndexOptions().name(INDEX_NAME)); + assertTrue(indexExists(INDEX_NAME), "Index should exist before drop"); + + MongoOperation operation = new MongoOperation(); + operation.setType("dropIndex"); + operation.setCollection(COLLECTION_NAME); + Map params = new HashMap<>(); + params.put("indexName", INDEX_NAME); + operation.setParameters(params); + + DropIndexOperator operator = new DropIndexOperator(mongoDatabase, operation); + operator.apply(null); + + assertFalse(indexExists(INDEX_NAME), "Index should have been dropped"); + } + + @Test + @DisplayName("WHEN dropIndex operator is applied with keys THEN index is dropped") + void dropIndexByKeysTest() { + mongoDatabase.createCollection(COLLECTION_NAME); + mongoDatabase.getCollection(COLLECTION_NAME).createIndex(new Document("name", 1)); + assertTrue(indexExistsByKeys("name"), "Index should exist before drop"); + + MongoOperation operation = new MongoOperation(); + operation.setType("dropIndex"); + operation.setCollection(COLLECTION_NAME); + Map params = new HashMap<>(); + Map keys = new HashMap<>(); + keys.put("name", 1); + params.put("keys", keys); + operation.setParameters(params); + + DropIndexOperator operator = new DropIndexOperator(mongoDatabase, operation); + operator.apply(null); + + assertFalse(indexExistsByKeys("name"), "Index should have been dropped"); + } + + private boolean indexExists(String indexName) { + List indexes = mongoDatabase.getCollection(COLLECTION_NAME) + .listIndexes() + .into(new ArrayList<>()); + return indexes.stream().anyMatch(idx -> indexName.equals(idx.getString("name"))); + } + + private boolean indexExistsByKeys(String keyField) { + List indexes = mongoDatabase.getCollection(COLLECTION_NAME) + .listIndexes() + .into(new ArrayList<>()); + return indexes.stream().anyMatch(idx -> { + Document key = idx.get("key", Document.class); + return key != null && key.containsKey(keyField); + }); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropViewOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropViewOperatorTest.java new file mode 100644 index 000000000..dfd2b1e4a --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropViewOperatorTest.java @@ -0,0 +1,93 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.DropViewOperator; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +class DropViewOperatorTest { + + private static final String DB_NAME = "test"; + private static final String SOURCE_COLLECTION = "dropViewSourceCollection"; + private static final String VIEW_NAME = "viewToDrop"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(SOURCE_COLLECTION).drop(); + mongoDatabase.getCollection(VIEW_NAME).drop(); + } + + @Test + @DisplayName("WHEN dropView operator is applied THEN view is dropped") + void dropViewTest() { + mongoDatabase.createCollection(SOURCE_COLLECTION); + mongoDatabase.createView(VIEW_NAME, SOURCE_COLLECTION, Collections.emptyList()); + assertTrue(collectionExists(VIEW_NAME), "View should exist before drop"); + + MongoOperation operation = new MongoOperation(); + operation.setType("dropView"); + operation.setCollection(VIEW_NAME); + operation.setParameters(new HashMap<>()); + + DropViewOperator operator = new DropViewOperator(mongoDatabase, operation); + operator.apply(null); + + assertFalse(collectionExists(VIEW_NAME), "View should have been dropped"); + } + + private boolean collectionExists(String collectionName) { + List collections = mongoDatabase.listCollectionNames().into(new ArrayList<>()); + return collections.contains(collectionName); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/InsertOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/InsertOperatorTest.java new file mode 100644 index 000000000..87334c1a6 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/InsertOperatorTest.java @@ -0,0 +1,174 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.InsertOperator; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Testcontainers +class InsertOperatorTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "insertTestCollection"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + mongoDatabase.createCollection(COLLECTION_NAME); + } + + @Test + @DisplayName("WHEN insert operator is applied with one document THEN document is inserted") + void insertOneDocumentTest() { + // Verify collection is empty before + assertEquals(0, getDocumentCount(), "Collection should be empty before insert"); + + // Create the operation + MongoOperation operation = new MongoOperation(); + operation.setType("insert"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + List> documents = new ArrayList<>(); + Map doc = new HashMap<>(); + doc.put("name", "John Doe"); + doc.put("email", "john@example.com"); + documents.add(doc); + params.put("documents", documents); + operation.setParameters(params); + + // Execute the operator + InsertOperator operator = new InsertOperator(mongoDatabase, operation); + operator.apply(null); + + // Verify + assertEquals(1, getDocumentCount(), "Collection should have one document"); + Document inserted = mongoDatabase.getCollection(COLLECTION_NAME).find().first(); + assertEquals("John Doe", inserted.getString("name")); + assertEquals("john@example.com", inserted.getString("email")); + } + + @Test + @DisplayName("WHEN insert operator is applied with multiple documents THEN documents are inserted") + void insertManyDocumentsTest() { + assertEquals(0, getDocumentCount(), "Collection should be empty before insert"); + + MongoOperation operation = new MongoOperation(); + operation.setType("insert"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + List> documents = new ArrayList<>(); + + Map doc1 = new HashMap<>(); + doc1.put("name", "Alice"); + doc1.put("email", "alice@example.com"); + documents.add(doc1); + + Map doc2 = new HashMap<>(); + doc2.put("name", "Bob"); + doc2.put("email", "bob@example.com"); + documents.add(doc2); + + Map doc3 = new HashMap<>(); + doc3.put("name", "Charlie"); + doc3.put("email", "charlie@example.com"); + documents.add(doc3); + + params.put("documents", documents); + operation.setParameters(params); + + InsertOperator operator = new InsertOperator(mongoDatabase, operation); + operator.apply(null); + + assertEquals(3, getDocumentCount(), "Collection should have three documents"); + + List insertedDocs = mongoDatabase.getCollection(COLLECTION_NAME) + .find() + .into(new ArrayList<>()); + + List names = Arrays.asList("Alice", "Bob", "Charlie"); + for (Document doc : insertedDocs) { + assertTrue(names.contains(doc.getString("name")), "Document name should be in expected list"); + } + } + + @Test + @DisplayName("WHEN insert operator is applied with empty documents THEN nothing is inserted") + void insertEmptyDocumentsTest() { + assertEquals(0, getDocumentCount(), "Collection should be empty before insert"); + + MongoOperation operation = new MongoOperation(); + operation.setType("insert"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + params.put("documents", new ArrayList<>()); + operation.setParameters(params); + + InsertOperator operator = new InsertOperator(mongoDatabase, operation); + operator.apply(null); + + assertEquals(0, getDocumentCount(), "Collection should still be empty"); + } + + private long getDocumentCount() { + return mongoDatabase.getCollection(COLLECTION_NAME).countDocuments(); + } + + private static void assertTrue(boolean condition, String message) { + if (!condition) { + throw new AssertionError(message); + } + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/ModifyCollectionOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/ModifyCollectionOperatorTest.java new file mode 100644 index 000000000..0a4536941 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/ModifyCollectionOperatorTest.java @@ -0,0 +1,96 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.ModifyCollectionOperator; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@Testcontainers +class ModifyCollectionOperatorTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "modifyTestCollection"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + } + + @Test + @DisplayName("WHEN modifyCollection operator is applied THEN collection validation is set") + void modifyCollectionTest() { + mongoDatabase.createCollection(COLLECTION_NAME); + + MongoOperation operation = new MongoOperation(); + operation.setType("modifyCollection"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map validator = new HashMap<>(); + Map jsonSchema = new HashMap<>(); + jsonSchema.put("bsonType", "object"); + validator.put("$jsonSchema", jsonSchema); + params.put("validator", validator); + params.put("validationLevel", "moderate"); + params.put("validationAction", "warn"); + operation.setParameters(params); + + ModifyCollectionOperator operator = new ModifyCollectionOperator(mongoDatabase, operation); + operator.apply(null); + + Document collectionInfo = mongoDatabase.listCollections() + .filter(new Document("name", COLLECTION_NAME)) + .first(); + assertNotNull(collectionInfo, "Collection should exist"); + Document options = collectionInfo.get("options", Document.class); + assertNotNull(options, "Collection should have options"); + assertNotNull(options.get("validator"), "Collection should have validator"); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/MultipleOperationsTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/MultipleOperationsTest.java new file mode 100644 index 000000000..b37a733a2 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/MultipleOperationsTest.java @@ -0,0 +1,196 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoApplyPayload; +import io.flamingock.template.mongodb.model.MongoOperation; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +class MultipleOperationsTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "multiOpsCollection"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + } + + @Test + @DisplayName("WHEN multiple operations are executed THEN all operations complete successfully") + void multipleOperationsTest() { + MongoApplyPayload payload = new MongoApplyPayload(); + List ops = new ArrayList<>(); + + // Op 1: Create collection + MongoOperation op1 = new MongoOperation(); + op1.setType("createCollection"); + op1.setCollection(COLLECTION_NAME); + op1.setParameters(new HashMap<>()); + ops.add(op1); + + // Op 2: Insert documents + MongoOperation op2 = new MongoOperation(); + op2.setType("insert"); + op2.setCollection(COLLECTION_NAME); + Map insertParams = new HashMap<>(); + List> documents = new ArrayList<>(); + Map doc1 = new HashMap<>(); + doc1.put("name", "User1"); + doc1.put("email", "user1@example.com"); + documents.add(doc1); + Map doc2 = new HashMap<>(); + doc2.put("name", "User2"); + doc2.put("email", "user2@example.com"); + documents.add(doc2); + insertParams.put("documents", documents); + op2.setParameters(insertParams); + ops.add(op2); + + // Op 3: Create index + MongoOperation op3 = new MongoOperation(); + op3.setType("createIndex"); + op3.setCollection(COLLECTION_NAME); + Map indexParams = new HashMap<>(); + Map keys = new HashMap<>(); + keys.put("email", 1); + indexParams.put("keys", keys); + Map options = new HashMap<>(); + options.put("unique", true); + indexParams.put("options", options); + op3.setParameters(indexParams); + ops.add(op3); + + payload.setOperations(ops); + + for (MongoOperation op : payload.getOperations()) { + op.getOperator(mongoDatabase).apply(null); + } + + assertTrue(collectionExists(COLLECTION_NAME), "Collection should exist"); + assertEquals(2, getDocumentCount(), "Should have 2 documents"); + assertTrue(indexExists("email"), "Index on email should exist"); + } + + @Test + @DisplayName("WHEN single operation format is used THEN backward compatibility works") + void backwardCompatibilitySingleOperationTest() { + MongoApplyPayload payload = new MongoApplyPayload(); + payload.setType("createCollection"); + payload.setCollection(COLLECTION_NAME); + payload.setParameters(new HashMap<>()); + + List ops = payload.getOperations(); + assertEquals(1, ops.size(), "Should have exactly one operation"); + assertEquals("createCollection", ops.get(0).getType()); + assertEquals(COLLECTION_NAME, ops.get(0).getCollection()); + + for (MongoOperation op : ops) { + op.getOperator(mongoDatabase).apply(null); + } + + assertTrue(collectionExists(COLLECTION_NAME), "Collection should exist"); + } + + @Test + @DisplayName("WHEN payload has no operations THEN empty list is returned") + void emptyPayloadTest() { + MongoApplyPayload payload = new MongoApplyPayload(); + + List ops = payload.getOperations(); + assertTrue(ops.isEmpty(), "Should return empty list"); + } + + @Test + @DisplayName("WHEN operations list is set but type is also set THEN operations list takes precedence") + void operationsListTakesPrecedenceTest() { + MongoApplyPayload payload = new MongoApplyPayload(); + + // Backward compatibility + payload.setType("dropCollection"); + payload.setCollection("someOtherCollection"); + + List ops = new ArrayList<>(); + MongoOperation op = new MongoOperation(); + op.setType("createCollection"); + op.setCollection(COLLECTION_NAME); + op.setParameters(new HashMap<>()); + ops.add(op); + payload.setOperations(ops); + + List result = payload.getOperations(); + assertEquals(1, result.size()); + assertEquals("createCollection", result.get(0).getType()); + assertEquals(COLLECTION_NAME, result.get(0).getCollection()); + } + + private boolean collectionExists(String collectionName) { + return mongoDatabase.listCollectionNames() + .into(new ArrayList<>()) + .contains(collectionName); + } + + private long getDocumentCount() { + return mongoDatabase.getCollection(COLLECTION_NAME).countDocuments(); + } + + private boolean indexExists(String keyField) { + List indexes = mongoDatabase.getCollection(COLLECTION_NAME) + .listIndexes() + .into(new ArrayList<>()); + return indexes.stream().anyMatch(idx -> { + Document key = idx.get("key", Document.class); + return key != null && key.containsKey(keyField); + }); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/PerOperationRollbackTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/PerOperationRollbackTest.java new file mode 100644 index 000000000..654526887 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/PerOperationRollbackTest.java @@ -0,0 +1,296 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoApplyPayload; +import io.flamingock.template.mongodb.model.MongoOperation; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@Testcontainers +class PerOperationRollbackTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "rollbackTestCollection"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + mongoDatabase.getCollection("failCollection").drop(); + } + + @Test + @DisplayName("WHEN all operations succeed THEN no rollback is triggered") + void successfulApplyNoRollbackTest() { + MongoApplyPayload payload = new MongoApplyPayload(); + List ops = new ArrayList<>(); + + MongoOperation op1 = createOperation("createCollection", COLLECTION_NAME); + op1.setRollback(createOperation("dropCollection", COLLECTION_NAME)); + ops.add(op1); + + MongoOperation op2 = createInsertOperation(COLLECTION_NAME, "TestUser"); + MongoOperation rollback2 = new MongoOperation(); + rollback2.setType("delete"); + rollback2.setCollection(COLLECTION_NAME); + Map deleteParams = new HashMap<>(); + deleteParams.put("filter", new HashMap<>()); + rollback2.setParameters(deleteParams); + op2.setRollback(rollback2); + ops.add(op2); + + payload.setOperations(ops); + + List successfulOps = new ArrayList<>(); + for (MongoOperation op : payload.getOperations()) { + op.getOperator(mongoDatabase).apply(null); + successfulOps.add(op); + } + + // All operations ok + assertTrue(collectionExists(COLLECTION_NAME), "Collection should exist"); + assertEquals(1, getDocumentCount(COLLECTION_NAME), "Should have 1 document"); + } + + @Test + @DisplayName("WHEN operation fails THEN previous operations are rolled back in reverse order") + void partialFailureAutoRollbackTest() { + mongoDatabase.createCollection(COLLECTION_NAME); + mongoDatabase.getCollection(COLLECTION_NAME).insertOne(new Document("name", "Existing")); + + MongoApplyPayload payload = new MongoApplyPayload(); + List ops = new ArrayList<>(); + + MongoOperation op1 = createInsertOperation(COLLECTION_NAME, "NewUser"); + MongoOperation rollback1 = new MongoOperation(); + rollback1.setType("delete"); + rollback1.setCollection(COLLECTION_NAME); + Map deleteParams = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("name", "NewUser"); + deleteParams.put("filter", filter); + rollback1.setParameters(deleteParams); + op1.setRollback(rollback1); + ops.add(op1); + + // Will fail, already exists + MongoOperation op2 = createOperation("createCollection", COLLECTION_NAME); + ops.add(op2); + + payload.setOperations(ops); + + List successfulOps = new ArrayList<>(); + Exception caughtException = null; + + for (MongoOperation op : payload.getOperations()) { + try { + op.getOperator(mongoDatabase).apply(null); + successfulOps.add(op); + } catch (Exception e) { + caughtException = e; + for (int i = successfulOps.size() - 1; i >= 0; i--) { + MongoOperation successfulOp = successfulOps.get(i); + if (successfulOp.getRollback() != null) { + successfulOp.getRollback().getOperator(mongoDatabase).apply(null); + } + } + break; + } + } + + assertNotNull(caughtException, "Should have caught an exception"); + assertTrue(collectionExists(COLLECTION_NAME), "Collection should still exist"); + + List docs = mongoDatabase.getCollection(COLLECTION_NAME) + .find() + .into(new ArrayList<>()); + assertEquals(1, docs.size(), "Should only have the original document"); + assertEquals("Existing", docs.get(0).getString("name"), "Only original document should remain"); + } + + @Test + @DisplayName("WHEN rollback is executed THEN operations are rolled back in reverse order") + void rollbackExecutesInReverseOrderTest() { + mongoDatabase.createCollection(COLLECTION_NAME); + List docs = new ArrayList<>(); + docs.add(new Document("name", "User1")); + docs.add(new Document("name", "User2")); + docs.add(new Document("name", "User3")); + mongoDatabase.getCollection(COLLECTION_NAME).insertMany(docs); + + MongoApplyPayload payload = new MongoApplyPayload(); + List ops = new ArrayList<>(); + + MongoOperation op1 = createInsertOperation(COLLECTION_NAME, "User1"); + MongoOperation rollback1 = createDeleteOperation(COLLECTION_NAME, "User1"); + op1.setRollback(rollback1); + ops.add(op1); + + MongoOperation op2 = createInsertOperation(COLLECTION_NAME, "User2"); + MongoOperation rollback2 = createDeleteOperation(COLLECTION_NAME, "User2"); + op2.setRollback(rollback2); + ops.add(op2); + + MongoOperation op3 = createInsertOperation(COLLECTION_NAME, "User3"); + MongoOperation rollback3 = createDeleteOperation(COLLECTION_NAME, "User3"); + op3.setRollback(rollback3); + ops.add(op3); + + payload.setOperations(ops); + + List operations = payload.getOperations(); + for (int i = operations.size() - 1; i >= 0; i--) { + MongoOperation op = operations.get(i); + if (op.getRollback() != null) { + op.getRollback().getOperator(mongoDatabase).apply(null); + } + } + + assertEquals(0, getDocumentCount(COLLECTION_NAME), "All documents should be deleted"); + } + + @Test + @DisplayName("WHEN operation has no rollback THEN it is skipped during rollback") + void missingRollbackIsSkippedTest() { + mongoDatabase.createCollection(COLLECTION_NAME); + + MongoApplyPayload payload = new MongoApplyPayload(); + List ops = new ArrayList<>(); + + MongoOperation op1 = createInsertOperation(COLLECTION_NAME, "User1"); + op1.setRollback(createDeleteOperation(COLLECTION_NAME, "User1")); + ops.add(op1); + + MongoOperation op2 = createInsertOperation(COLLECTION_NAME, "User2"); + ops.add(op2); + + MongoOperation op3 = createInsertOperation(COLLECTION_NAME, "User3"); + op3.setRollback(createDeleteOperation(COLLECTION_NAME, "User3")); + ops.add(op3); + + payload.setOperations(ops); + + for (MongoOperation op : payload.getOperations()) { + op.getOperator(mongoDatabase).apply(null); + } + + assertEquals(3, getDocumentCount(COLLECTION_NAME), "Should have 3 documents"); + + List operations = payload.getOperations(); + for (int i = operations.size() - 1; i >= 0; i--) { + MongoOperation op = operations.get(i); + if (op.getRollback() != null) { + op.getRollback().getOperator(mongoDatabase).apply(null); + } + } + + List docs = mongoDatabase.getCollection(COLLECTION_NAME) + .find() + .into(new ArrayList<>()); + assertEquals(1, docs.size(), "Only User2 should remain"); + assertEquals("User2", docs.get(0).getString("name")); + } + + @Test + @DisplayName("WHEN MongoOperation has rollback defined THEN getRollback returns it") + void getRollbackReturnsDefinedRollbackTest() { + MongoOperation op = createOperation("createCollection", COLLECTION_NAME); + MongoOperation rollback = createOperation("dropCollection", COLLECTION_NAME); + op.setRollback(rollback); + + assertEquals(rollback, op.getRollback()); + assertEquals("dropCollection", op.getRollback().getType()); + assertEquals(COLLECTION_NAME, op.getRollback().getCollection()); + } + + private MongoOperation createOperation(String type, String collection) { + MongoOperation op = new MongoOperation(); + op.setType(type); + op.setCollection(collection); + op.setParameters(new HashMap<>()); + return op; + } + + private MongoOperation createInsertOperation(String collection, String userName) { + MongoOperation op = new MongoOperation(); + op.setType("insert"); + op.setCollection(collection); + Map params = new HashMap<>(); + List> docs = new ArrayList<>(); + Map doc = new HashMap<>(); + doc.put("name", userName); + docs.add(doc); + params.put("documents", docs); + op.setParameters(params); + return op; + } + + private MongoOperation createDeleteOperation(String collection, String userName) { + MongoOperation op = new MongoOperation(); + op.setType("delete"); + op.setCollection(collection); + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("name", userName); + params.put("filter", filter); + op.setParameters(params); + return op; + } + + private boolean collectionExists(String collectionName) { + return mongoDatabase.listCollectionNames() + .into(new ArrayList<>()) + .contains(collectionName); + } + + private long getDocumentCount(String collectionName) { + return mongoDatabase.getCollection(collectionName).countDocuments(); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/RenameCollectionOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/RenameCollectionOperatorTest.java new file mode 100644 index 000000000..e9c576b54 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/RenameCollectionOperatorTest.java @@ -0,0 +1,94 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.RenameCollectionOperator; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Testcontainers +class RenameCollectionOperatorTest { + + private static final String DB_NAME = "test"; + private static final String ORIGINAL_NAME = "originalCollection"; + private static final String RENAMED_NAME = "renamedCollection"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(ORIGINAL_NAME).drop(); + mongoDatabase.getCollection(RENAMED_NAME).drop(); + } + + @Test + @DisplayName("WHEN renameCollection operator is applied THEN collection is renamed") + void renameCollectionTest() { + mongoDatabase.createCollection(ORIGINAL_NAME); + assertTrue(collectionExists(ORIGINAL_NAME), "Original collection should exist before rename"); + + MongoOperation operation = new MongoOperation(); + operation.setType("renameCollection"); + operation.setCollection(ORIGINAL_NAME); + Map params = new HashMap<>(); + params.put("target", RENAMED_NAME); + operation.setParameters(params); + + RenameCollectionOperator operator = new RenameCollectionOperator(mongoDatabase, operation); + operator.apply(null); + + assertFalse(collectionExists(ORIGINAL_NAME), "Original collection should not exist after rename"); + assertTrue(collectionExists(RENAMED_NAME), "Renamed collection should exist"); + } + + private boolean collectionExists(String collectionName) { + List collections = mongoDatabase.listCollectionNames().into(new ArrayList<>()); + return collections.contains(collectionName); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/UpdateOperatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/UpdateOperatorTest.java new file mode 100644 index 000000000..e8f680387 --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/UpdateOperatorTest.java @@ -0,0 +1,267 @@ +/* + * 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.operations; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoDatabase; +import io.flamingock.template.mongodb.model.MongoOperation; +import io.flamingock.template.mongodb.model.operator.UpdateOperator; +import org.bson.Document; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +@Testcontainers +class UpdateOperatorTest { + + private static final String DB_NAME = "test"; + private static final String COLLECTION_NAME = "updateTestCollection"; + + private static MongoClient mongoClient; + private static MongoDatabase mongoDatabase; + + @Container + public static final MongoDBContainer mongoDBContainer = new MongoDBContainer(DockerImageName.parse("mongo:6")); + + @BeforeAll + static void beforeAll() { + mongoClient = MongoClients.create(MongoClientSettings + .builder() + .applyConnectionString(new ConnectionString(mongoDBContainer.getConnectionString())) + .build()); + mongoDatabase = mongoClient.getDatabase(DB_NAME); + } + + @BeforeEach + void setupEach() { + mongoDatabase.getCollection(COLLECTION_NAME).drop(); + mongoDatabase.createCollection(COLLECTION_NAME); + List docs = new ArrayList<>(); + docs.add(new Document("name", "Alice").append("role", "admin").append("score", 100)); + docs.add(new Document("name", "Bob").append("role", "user").append("score", 80)); + docs.add(new Document("name", "Charlie").append("role", "user").append("score", 90)); + docs.add(new Document("name", "Diana").append("role", "admin").append("score", 95)); + mongoDatabase.getCollection(COLLECTION_NAME).insertMany(docs); + } + + @Test + @DisplayName("WHEN updateOne is applied THEN only first matching document is updated") + void updateOneTest() { + MongoOperation operation = new MongoOperation(); + operation.setType("update"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("role", "user"); + params.put("filter", filter); + + Map update = new HashMap<>(); + Map setFields = new HashMap<>(); + setFields.put("role", "guest"); + update.put("$set", setFields); + params.put("update", update); + // multi defaults to false + operation.setParameters(params); + + UpdateOperator operator = new UpdateOperator(mongoDatabase, operation); + operator.apply(null); + + // Only one user should be updated to guest + long guestCount = mongoDatabase.getCollection(COLLECTION_NAME) + .countDocuments(new Document("role", "guest")); + assertEquals(1, guestCount, "Only one document should be updated"); + + long userCount = mongoDatabase.getCollection(COLLECTION_NAME) + .countDocuments(new Document("role", "user")); + assertEquals(1, userCount, "One user document should remain"); + } + + @Test + @DisplayName("WHEN updateMany is applied THEN all matching documents are updated") + void updateManyTest() { + MongoOperation operation = new MongoOperation(); + operation.setType("update"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("role", "user"); + params.put("filter", filter); + + Map update = new HashMap<>(); + Map setFields = new HashMap<>(); + setFields.put("role", "guest"); + update.put("$set", setFields); + params.put("update", update); + params.put("multi", true); + operation.setParameters(params); + + UpdateOperator operator = new UpdateOperator(mongoDatabase, operation); + operator.apply(null); + + long guestCount = mongoDatabase.getCollection(COLLECTION_NAME) + .countDocuments(new Document("role", "guest")); + assertEquals(2, guestCount, "Both user documents should be updated to guest"); + + long userCount = mongoDatabase.getCollection(COLLECTION_NAME) + .countDocuments(new Document("role", "user")); + assertEquals(0, userCount, "No user documents should remain"); + } + + @Test + @DisplayName("WHEN update with upsert option and no match THEN new document is created") + void updateWithUpsertTest() { + long initialCount = getDocumentCount(); + + MongoOperation operation = new MongoOperation(); + operation.setType("update"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("name", "NewUser"); + params.put("filter", filter); + + Map update = new HashMap<>(); + Map setFields = new HashMap<>(); + setFields.put("name", "NewUser"); + setFields.put("role", "new"); + update.put("$set", setFields); + params.put("update", update); + + Map options = new HashMap<>(); + options.put("upsert", true); + params.put("options", options); + + operation.setParameters(params); + + UpdateOperator operator = new UpdateOperator(mongoDatabase, operation); + operator.apply(null); + + assertEquals(initialCount + 1, getDocumentCount(), "New document should be created via upsert"); + + Document newDoc = mongoDatabase.getCollection(COLLECTION_NAME) + .find(new Document("name", "NewUser")) + .first(); + assertNotNull(newDoc, "Upserted document should exist"); + assertEquals("new", newDoc.getString("role")); + } + + @Test + @DisplayName("WHEN update with $inc operator THEN numeric field is incremented") + void updateWithIncTest() { + MongoOperation operation = new MongoOperation(); + operation.setType("update"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("name", "Alice"); + params.put("filter", filter); + + Map update = new HashMap<>(); + Map incFields = new HashMap<>(); + incFields.put("score", 10); + update.put("$inc", incFields); + params.put("update", update); + operation.setParameters(params); + + UpdateOperator operator = new UpdateOperator(mongoDatabase, operation); + operator.apply(null); + + Document alice = mongoDatabase.getCollection(COLLECTION_NAME) + .find(new Document("name", "Alice")) + .first(); + assertNotNull(alice); + assertEquals(110, alice.getInteger("score"), "Score should be incremented by 10"); + } + + @Test + @DisplayName("WHEN update with non-matching filter THEN no documents are updated") + void updateWithNonMatchingFilterTest() { + MongoOperation operation = new MongoOperation(); + operation.setType("update"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("name", "NonExistent"); + params.put("filter", filter); + + Map update = new HashMap<>(); + Map setFields = new HashMap<>(); + setFields.put("role", "changed"); + update.put("$set", setFields); + params.put("update", update); + operation.setParameters(params); + + UpdateOperator operator = new UpdateOperator(mongoDatabase, operation); + operator.apply(null); + + long changedCount = mongoDatabase.getCollection(COLLECTION_NAME) + .countDocuments(new Document("role", "changed")); + assertEquals(0, changedCount, "No documents should be updated"); + } + + @Test + @DisplayName("WHEN update with $unset operator THEN field is removed") + void updateWithUnsetTest() { + MongoOperation operation = new MongoOperation(); + operation.setType("update"); + operation.setCollection(COLLECTION_NAME); + + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("name", "Alice"); + params.put("filter", filter); + + Map update = new HashMap<>(); + Map unsetFields = new HashMap<>(); + unsetFields.put("score", ""); + update.put("$unset", unsetFields); + params.put("update", update); + operation.setParameters(params); + + UpdateOperator operator = new UpdateOperator(mongoDatabase, operation); + operator.apply(null); + + Document alice = mongoDatabase.getCollection(COLLECTION_NAME) + .find(new Document("name", "Alice")) + .first(); + assertNotNull(alice); + assertNull(alice.getInteger("score"), "Score field should be removed"); + } + + private long getDocumentCount() { + return mongoDatabase.getCollection(COLLECTION_NAME).countDocuments(); + } +} diff --git a/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/validation/MongoOperationValidatorTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/validation/MongoOperationValidatorTest.java new file mode 100644 index 000000000..629120e1b --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/validation/MongoOperationValidatorTest.java @@ -0,0 +1,810 @@ +/* + * 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.validation; + +import io.flamingock.template.mongodb.model.MongoOperation; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MongoOperationValidatorTest { + + private static final String ENTITY_ID = "test-change"; + + @Nested + @DisplayName("Common Validation Tests") + class CommonValidationTests { + + @Test + @DisplayName("WHEN operation is null THEN validation fails") + void nullOperationTest() { + List errors = MongoOperationValidator.validate(null, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("cannot be null")); + } + + @Test + @DisplayName("WHEN operation type is null THEN validation fails") + void nullOperationTypeTest() { + MongoOperation op = new MongoOperation(); + op.setType(null); + op.setCollection("test"); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("type is required")); + } + + @Test + @DisplayName("WHEN operation type is empty THEN validation fails") + void emptyOperationTypeTest() { + MongoOperation op = new MongoOperation(); + op.setType(""); + op.setCollection("test"); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("type is required")); + } + + @Test + @DisplayName("WHEN operation type is unknown THEN validation fails") + void unknownOperationTypeTest() { + MongoOperation op = new MongoOperation(); + op.setType("unknownType"); + op.setCollection("test"); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("Unknown operation type")); + } + + @Test + @DisplayName("WHEN collection is null THEN validation fails") + void nullCollectionTest() { + MongoOperation op = new MongoOperation(); + op.setType("createCollection"); + op.setCollection(null); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("Collection name is required")); + } + + @Test + @DisplayName("WHEN collection is empty THEN validation fails") + void emptyCollectionTest() { + MongoOperation op = new MongoOperation(); + op.setType("createCollection"); + op.setCollection(""); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("cannot be empty")); + } + + @Test + @DisplayName("WHEN collection contains $ THEN validation fails") + void collectionWithDollarSignTest() { + MongoOperation op = new MongoOperation(); + op.setType("createCollection"); + op.setCollection("test$collection"); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("cannot contain '$'")); + } + + @Test + @DisplayName("WHEN collection contains null char THEN validation fails") + void collectionWithNullCharTest() { + MongoOperation op = new MongoOperation(); + op.setType("createCollection"); + op.setCollection("test\0collection"); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("null character")); + } + } + + @Nested + @DisplayName("Insert Operation Tests") + class InsertOperationTests { + + @Test + @DisplayName("WHEN insert missing parameters THEN validation fails") + void insertMissingParametersTest() { + MongoOperation op = new MongoOperation(); + op.setType("insert"); + op.setCollection("test"); + op.setParameters(null); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'parameters'")); + } + + @Test + @DisplayName("WHEN insert missing documents THEN validation fails") + void insertMissingDocumentsTest() { + MongoOperation op = new MongoOperation(); + op.setType("insert"); + op.setCollection("test"); + op.setParameters(new HashMap<>()); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'documents'")); + } + + @Test + @DisplayName("WHEN insert has empty documents array THEN validation fails") + void insertEmptyDocumentsTest() { + MongoOperation op = new MongoOperation(); + op.setType("insert"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("documents", new ArrayList<>()); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("cannot be empty")); + } + + @Test + @DisplayName("WHEN insert has null element in documents THEN validation fails") + void insertNullDocumentElementTest() { + MongoOperation op = new MongoOperation(); + op.setType("insert"); + op.setCollection("test"); + Map params = new HashMap<>(); + List> docs = new ArrayList<>(); + docs.add(null); + params.put("documents", docs); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("index 0 is null")); + } + + @Test + @DisplayName("WHEN insert has documents as wrong type THEN validation fails") + void insertDocumentsWrongTypeTest() { + MongoOperation op = new MongoOperation(); + op.setType("insert"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("documents", "not a list"); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("must be a list")); + } + + @Test + @DisplayName("WHEN insert is valid THEN validation passes") + void insertValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("insert"); + op.setCollection("test"); + Map params = new HashMap<>(); + List> docs = new ArrayList<>(); + Map doc = new HashMap<>(); + doc.put("name", "Test"); + docs.add(doc); + params.put("documents", docs); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + } + + @Nested + @DisplayName("Update Operation Tests") + class UpdateOperationTests { + + @Test + @DisplayName("WHEN update missing parameters THEN validation fails") + void updateMissingParametersTest() { + MongoOperation op = new MongoOperation(); + op.setType("update"); + op.setCollection("test"); + op.setParameters(null); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'parameters'")); + } + + @Test + @DisplayName("WHEN update missing filter THEN validation fails") + void updateMissingFilterTest() { + MongoOperation op = new MongoOperation(); + op.setType("update"); + op.setCollection("test"); + Map params = new HashMap<>(); + Map update = new HashMap<>(); + update.put("$set", new HashMap<>()); + params.put("update", update); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'filter'")); + } + + @Test + @DisplayName("WHEN update missing update param THEN validation fails") + void updateMissingUpdateParamTest() { + MongoOperation op = new MongoOperation(); + op.setType("update"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("filter", new HashMap<>()); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'update'")); + } + + @Test + @DisplayName("WHEN update param is wrong type THEN validation fails") + void updateParamWrongTypeTest() { + MongoOperation op = new MongoOperation(); + op.setType("update"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("filter", new HashMap<>()); + params.put("update", "not a document"); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("must be a document")); + } + + @Test + @DisplayName("WHEN update missing both filter and update THEN both errors reported") + void updateMissingBothTest() { + MongoOperation op = new MongoOperation(); + op.setType("update"); + op.setCollection("test"); + op.setParameters(new HashMap<>()); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(2, errors.size()); + } + + @Test + @DisplayName("WHEN update is valid THEN validation passes") + void updateValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("update"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("filter", new HashMap<>()); + Map update = new HashMap<>(); + Map setFields = new HashMap<>(); + setFields.put("status", "active"); + update.put("$set", setFields); + params.put("update", update); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + + @Test + @DisplayName("WHEN update with multi option is valid THEN validation passes") + void updateWithMultiValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("update"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("filter", new HashMap<>()); + Map update = new HashMap<>(); + update.put("$set", new HashMap<>()); + params.put("update", update); + params.put("multi", true); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + } + + @Nested + @DisplayName("Delete Operation Tests") + class DeleteOperationTests { + + @Test + @DisplayName("WHEN delete missing filter THEN validation fails") + void deleteMissingFilterTest() { + MongoOperation op = new MongoOperation(); + op.setType("delete"); + op.setCollection("test"); + op.setParameters(new HashMap<>()); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'filter'")); + } + + @Test + @DisplayName("WHEN delete has empty filter THEN validation passes") + void deleteEmptyFilterTest() { + MongoOperation op = new MongoOperation(); + op.setType("delete"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("filter", new HashMap<>()); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + + @Test + @DisplayName("WHEN delete is valid THEN validation passes") + void deleteValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("delete"); + op.setCollection("test"); + Map params = new HashMap<>(); + Map filter = new HashMap<>(); + filter.put("name", "Test"); + params.put("filter", filter); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + } + + @Nested + @DisplayName("CreateIndex Operation Tests") + class CreateIndexOperationTests { + + @Test + @DisplayName("WHEN createIndex missing parameters THEN validation fails") + void createIndexMissingParametersTest() { + MongoOperation op = new MongoOperation(); + op.setType("createIndex"); + op.setCollection("test"); + op.setParameters(null); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'parameters'")); + } + + @Test + @DisplayName("WHEN createIndex missing keys THEN validation fails") + void createIndexMissingKeysTest() { + MongoOperation op = new MongoOperation(); + op.setType("createIndex"); + op.setCollection("test"); + op.setParameters(new HashMap<>()); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'keys'")); + } + + @Test + @DisplayName("WHEN createIndex has empty keys THEN validation fails") + void createIndexEmptyKeysTest() { + MongoOperation op = new MongoOperation(); + op.setType("createIndex"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("keys", new HashMap<>()); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("cannot be empty")); + } + + @Test + @DisplayName("WHEN createIndex keys is wrong type THEN validation fails") + void createIndexKeysWrongTypeTest() { + MongoOperation op = new MongoOperation(); + op.setType("createIndex"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("keys", "not a map"); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("must be a map")); + } + + @Test + @DisplayName("WHEN createIndex is valid THEN validation passes") + void createIndexValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("createIndex"); + op.setCollection("test"); + Map params = new HashMap<>(); + Map keys = new HashMap<>(); + keys.put("email", 1); + params.put("keys", keys); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + } + + @Nested + @DisplayName("DropIndex Operation Tests") + class DropIndexOperationTests { + + @Test + @DisplayName("WHEN dropIndex missing both indexName and keys THEN validation fails") + void dropIndexMissingBothTest() { + MongoOperation op = new MongoOperation(); + op.setType("dropIndex"); + op.setCollection("test"); + op.setParameters(new HashMap<>()); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("'indexName' or 'keys'")); + } + + @Test + @DisplayName("WHEN dropIndex has indexName THEN validation passes") + void dropIndexWithIndexNameTest() { + MongoOperation op = new MongoOperation(); + op.setType("dropIndex"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("indexName", "email_index"); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + + @Test + @DisplayName("WHEN dropIndex has keys THEN validation passes") + void dropIndexWithKeysTest() { + MongoOperation op = new MongoOperation(); + op.setType("dropIndex"); + op.setCollection("test"); + Map params = new HashMap<>(); + Map keys = new HashMap<>(); + keys.put("email", 1); + params.put("keys", keys); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + } + + @Nested + @DisplayName("RenameCollection Operation Tests") + class RenameCollectionOperationTests { + + @Test + @DisplayName("WHEN renameCollection missing target THEN validation fails") + void renameCollectionMissingTargetTest() { + MongoOperation op = new MongoOperation(); + op.setType("renameCollection"); + op.setCollection("test"); + op.setParameters(new HashMap<>()); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'target'")); + } + + @Test + @DisplayName("WHEN renameCollection target is empty THEN validation fails") + void renameCollectionEmptyTargetTest() { + MongoOperation op = new MongoOperation(); + op.setType("renameCollection"); + op.setCollection("test"); + Map params = new HashMap<>(); + params.put("target", ""); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("cannot be null or empty")); + } + + @Test + @DisplayName("WHEN renameCollection is valid THEN validation passes") + void renameCollectionValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("renameCollection"); + op.setCollection("oldName"); + Map params = new HashMap<>(); + params.put("target", "newName"); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + } + + @Nested + @DisplayName("CreateView Operation Tests") + class CreateViewOperationTests { + + @Test + @DisplayName("WHEN createView missing parameters THEN validation fails") + void createViewMissingParametersTest() { + MongoOperation op = new MongoOperation(); + op.setType("createView"); + op.setCollection("testView"); + op.setParameters(null); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'parameters'")); + } + + @Test + @DisplayName("WHEN createView missing viewOn THEN validation fails") + void createViewMissingViewOnTest() { + MongoOperation op = new MongoOperation(); + op.setType("createView"); + op.setCollection("testView"); + Map params = new HashMap<>(); + params.put("pipeline", Collections.singletonList(new HashMap<>())); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'viewOn'")); + } + + @Test + @DisplayName("WHEN createView missing pipeline THEN validation fails") + void createViewMissingPipelineTest() { + MongoOperation op = new MongoOperation(); + op.setType("createView"); + op.setCollection("testView"); + Map params = new HashMap<>(); + params.put("viewOn", "sourceCollection"); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("requires 'pipeline'")); + } + + @Test + @DisplayName("WHEN createView pipeline is wrong type THEN validation fails") + void createViewPipelineWrongTypeTest() { + MongoOperation op = new MongoOperation(); + op.setType("createView"); + op.setCollection("testView"); + Map params = new HashMap<>(); + params.put("viewOn", "sourceCollection"); + params.put("pipeline", "not a list"); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getMessage().contains("must be a list")); + } + + @Test + @DisplayName("WHEN createView is valid THEN validation passes") + void createViewValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("createView"); + op.setCollection("testView"); + Map params = new HashMap<>(); + params.put("viewOn", "sourceCollection"); + params.put("pipeline", Collections.singletonList(new HashMap<>())); + op.setParameters(params); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + } + + @Nested + @DisplayName("Simple Operation Tests") + class SimpleOperationTests { + + @Test + @DisplayName("WHEN createCollection is valid THEN validation passes") + void createCollectionValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("createCollection"); + op.setCollection("test"); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + + @Test + @DisplayName("WHEN dropCollection is valid THEN validation passes") + void dropCollectionValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("dropCollection"); + op.setCollection("test"); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + + @Test + @DisplayName("WHEN dropView is valid THEN validation passes") + void dropViewValidTest() { + MongoOperation op = new MongoOperation(); + op.setType("dropView"); + op.setCollection("testView"); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + } + + @Nested + @DisplayName("Rollback Validation Tests") + class RollbackValidationTests { + + @Test + @DisplayName("WHEN rollback operation is invalid THEN validation fails with rollback path") + void invalidRollbackTest() { + MongoOperation rollback = new MongoOperation(); + rollback.setType("insert"); + rollback.setCollection("test"); + rollback.setParameters(new HashMap<>()); // missing documents + + MongoOperation op = new MongoOperation(); + op.setType("createCollection"); + op.setCollection("test"); + op.setRollback(rollback); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getEntityId().contains(".rollback")); + assertTrue(errors.get(0).getMessage().contains("documents")); + } + + @Test + @DisplayName("WHEN rollback operation is valid THEN validation passes") + void validRollbackTest() { + MongoOperation rollback = new MongoOperation(); + rollback.setType("dropCollection"); + rollback.setCollection("test"); + + MongoOperation op = new MongoOperation(); + op.setType("createCollection"); + op.setCollection("test"); + op.setRollback(rollback); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertTrue(errors.isEmpty()); + } + + @Test + @DisplayName("WHEN nested rollback is invalid THEN validation fails with nested path") + void nestedInvalidRollbackTest() { + MongoOperation nestedRollback = new MongoOperation(); + nestedRollback.setType("unknownType"); + nestedRollback.setCollection("test"); + + MongoOperation rollback = new MongoOperation(); + rollback.setType("dropCollection"); + rollback.setCollection("test"); + rollback.setRollback(nestedRollback); + + MongoOperation op = new MongoOperation(); + op.setType("createCollection"); + op.setCollection("test"); + op.setRollback(rollback); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(1, errors.size()); + assertTrue(errors.get(0).getEntityId().contains(".rollback.rollback")); + } + } + + @Nested + @DisplayName("Multiple Errors Tests") + class MultipleErrorsTests { + + @Test + @DisplayName("WHEN multiple validation errors exist THEN all are collected") + void multipleErrorsTest() { + MongoOperation rollback = new MongoOperation(); + rollback.setType("insert"); + rollback.setCollection(""); + rollback.setParameters(new HashMap<>()); + + MongoOperation op = new MongoOperation(); + op.setType("insert"); + op.setCollection("test$invalid"); + Map params = new HashMap<>(); + params.put("documents", new ArrayList<>()); + op.setParameters(params); + op.setRollback(rollback); + + List errors = MongoOperationValidator.validate(op, ENTITY_ID); + + assertEquals(4, errors.size()); + } + } +}