From dbbb85ac4e160eb40c85f2984c80d49c4c036d52 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Wed, 31 Dec 2025 16:18:02 +0000 Subject: [PATCH 1/6] feat: mongodb sync template new operations and unit tests --- .../mapper/CreateViewOptionsMapper.java | 37 ++++ .../mapper/RenameCollectionOptionsMapper.java | 37 ++++ .../mongodb/model/MongoOperation.java | 34 ++++ .../mongodb/model/MongoOperationType.java | 14 +- .../model/operator/CreateViewOperator.java | 35 ++++ .../operator/DropCollectionOperator.java | 32 ++++ .../model/operator/DropIndexOperator.java | 37 ++++ .../model/operator/DropViewOperator.java | 32 ++++ .../operator/ModifyCollectionOperator.java | 43 +++++ .../operator/RenameCollectionOperator.java | 37 ++++ .../CreateCollectionOperatorTest.java | 87 +++++++++ .../operations/CreateIndexOperatorTest.java | 143 ++++++++++++++ .../operations/CreateViewOperatorTest.java | 110 +++++++++++ .../DropCollectionOperatorTest.java | 88 +++++++++ .../operations/DropIndexOperatorTest.java | 129 +++++++++++++ .../operations/DropViewOperatorTest.java | 93 ++++++++++ .../operations/InsertOperatorTest.java | 174 ++++++++++++++++++ .../ModifyCollectionOperatorTest.java | 96 ++++++++++ .../RenameCollectionOperatorTest.java | 94 ++++++++++ 19 files changed, 1351 insertions(+), 1 deletion(-) create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/CreateViewOptionsMapper.java create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/RenameCollectionOptionsMapper.java create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/CreateViewOperator.java create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropCollectionOperator.java create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropIndexOperator.java create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DropViewOperator.java create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/ModifyCollectionOperator.java create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/RenameCollectionOperator.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateCollectionOperatorTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateIndexOperatorTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/CreateViewOperatorTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropCollectionOperatorTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropIndexOperatorTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DropViewOperatorTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/InsertOperatorTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/ModifyCollectionOperatorTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/RenameCollectionOperatorTest.java 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/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/model/MongoOperation.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperation.java index 4d931e157..115d09c55 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 @@ -69,6 +69,40 @@ 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; + } public MongoOperator getOperator(MongoDatabase db) { return MongoOperationType.getFromValue(getType()).getOperator(db, this); 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..4cafa1b3c 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,14 @@ 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.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 java.util.Arrays; import java.util.function.BiFunction; @@ -28,7 +34,13 @@ public enum MongoOperationType { CREATE_COLLECTION("createCollection", CreateCollectionOperator::new), CREATE_INDEX("createIndex", CreateIndexOperator::new), - INSERT("insert", InsertOperator::new); + INSERT("insert", InsertOperator::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/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/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/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/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/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/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); + } +} From d4d9fe33d58f5402c27627b324b851a44a2c8ba8 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Sat, 3 Jan 2026 12:39:34 +0000 Subject: [PATCH 2/6] feat: mongodb sync template support for multiple operations --- .../template/mongodb/MongoChangeTemplate.java | 16 +- .../mongodb/model/MongoApplyPayload.java | 115 ++++++++++ .../mongodb/MongoChangeTemplateTest.java | 29 ++- .../changes/_0003__multiple_operations.yaml | 31 +++ .../operations/MultipleOperationsTest.java | 196 ++++++++++++++++++ 5 files changed, 380 insertions(+), 7 deletions(-) create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoApplyPayload.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/changes/_0003__multiple_operations.yaml create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/MultipleOperationsTest.java 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..21dfd6fc0 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,10 @@ import io.flamingock.api.annotations.Nullable; import io.flamingock.api.annotations.Rollback; import io.flamingock.api.template.AbstractChangeTemplate; +import io.flamingock.template.mongodb.model.MongoApplyPayload; import io.flamingock.template.mongodb.model.MongoOperation; -public class MongoChangeTemplate extends AbstractChangeTemplate { +public class MongoChangeTemplate extends AbstractChangeTemplate { public MongoChangeTemplate() { super(MongoOperation.class); @@ -34,7 +35,7 @@ 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); + executeOperations(db, applyPayload, clientSession); } @Rollback @@ -42,11 +43,16 @@ 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); + executeOperations(db, rollbackPayload, clientSession); } - private void executeOp(MongoDatabase db, MongoOperation op, ClientSession clientSession) { - op.getOperator(db).apply(clientSession); + private void executeOperations(MongoDatabase db, MongoApplyPayload payload, ClientSession clientSession) { + if (payload == null) { + return; + } + for (MongoOperation op : payload.getOperations()) { + op.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/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/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/MongoChangeTemplateTest.java index e0318a0d5..564e06c70 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,8 @@ 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(); } @@ -88,7 +90,7 @@ void happyPath() { .find() .into(new ArrayList<>()); - assertEquals(4, auditLog.size()); + assertEquals(6, 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 +102,12 @@ 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")); + + // Verify for single operation List users = mongoDatabase.getCollection("users") .find() .into(new ArrayList<>()); @@ -112,6 +120,23 @@ 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"); } 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/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); + }); + } +} From 2e3346df7c7ec23a55a481f71ab435a6ce094fc9 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Fri, 9 Jan 2026 15:13:15 +0000 Subject: [PATCH 3/6] feat: mongodb sync template support for rollback --- .../template/mongodb/MongoChangeTemplate.java | 118 ++++++- .../mongodb/model/MongoOperation.java | 10 +- .../mongodb/model/MongoOperationType.java | 2 + .../model/operator/DeleteOperator.java | 41 +++ .../mongodb/AutoRollbackTemplateTest.java | 145 +++++++++ .../mongodb/MongoChangeTemplateTest.java | 26 +- .../_0004__per_operation_rollback.yaml | 42 +++ .../operations/DeleteOperatorTest.java | 174 ++++++++++ .../operations/PerOperationRollbackTest.java | 296 ++++++++++++++++++ 9 files changed, 846 insertions(+), 8 deletions(-) create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/DeleteOperator.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/AutoRollbackTemplateTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/changes/_0004__per_operation_rollback.yaml create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/DeleteOperatorTest.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/PerOperationRollbackTest.java 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 21dfd6fc0..de8bdcaf9 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,11 +21,73 @@ 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 org.slf4j.Logger; +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})

+ *
    + *
  • All operations execute within a MongoDB transaction (ClientSession)
  • + *
  • If any operation fails, MongoDB automatically rolls back the entire transaction
  • + *
  • Per-operation rollback definitions are NOT executed (the transaction handles atomicity)
  • + *
+ * + *

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

+ *
    + *
  • Each operation executes independently without a transaction
  • + *
  • Successfully completed operations are tracked
  • + *
  • If operation N fails, auto-rollback is triggered for operations 1 to N-1
  • + *
  • Auto-rollback executes per-operation rollbacks in reverse order
  • + *
  • Operations without a rollback definition are skipped during auto-rollback
  • + *
  • After auto-rollback completes, the original exception is re-thrown
  • + *
+ * + *

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); } @@ -35,7 +97,7 @@ 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)); } - executeOperations(db, applyPayload, clientSession); + executeOperationsWithAutoRollback(db, applyPayload, clientSession); } @Rollback @@ -43,15 +105,61 @@ 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)); } - executeOperations(db, rollbackPayload, clientSession); + executeRollbackOperations(db, applyPayload, 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 executeOperations(MongoDatabase db, MongoApplyPayload payload, ClientSession clientSession) { + private void executeRollbackOperations(MongoDatabase db, MongoApplyPayload payload, ClientSession clientSession) { if (payload == null) { return; } - for (MongoOperation op : payload.getOperations()) { - op.getOperator(db).apply(clientSession); + + 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); + } } } 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 115d09c55..80460a487 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")) @@ -110,10 +115,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 4cafa1b3c..2180591e0 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 @@ -19,6 +19,7 @@ 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; @@ -35,6 +36,7 @@ public enum MongoOperationType { CREATE_COLLECTION("createCollection", CreateCollectionOperator::new), CREATE_INDEX("createIndex", CreateIndexOperator::new), INSERT("insert", InsertOperator::new), + DELETE("delete", DeleteOperator::new), DROP_COLLECTION("dropCollection", DropCollectionOperator::new), DROP_INDEX("dropIndex", DropIndexOperator::new), RENAME_COLLECTION("renameCollection", RenameCollectionOperator::new), 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/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 564e06c70..e71b75489 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 @@ -71,6 +71,7 @@ void setupEach() { mongoDatabase.getCollection(DEFAULT_AUDIT_STORE_NAME).drop(); mongoDatabase.getCollection("users").drop(); mongoDatabase.getCollection("products").drop(); + mongoDatabase.getCollection("orders").drop(); } @@ -90,7 +91,7 @@ void happyPath() { .find() .into(new ArrayList<>()); - assertEquals(6, 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")); @@ -107,6 +108,11 @@ void happyPath() { 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() @@ -137,7 +143,23 @@ void happyPath() { 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/_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/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/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(); + } +} From 81e4fb86061a1833bb3915028959ac869147ab5e Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Sun, 11 Jan 2026 11:43:57 +0000 Subject: [PATCH 4/6] feat: mongodb sync template validation layer --- .../template/mongodb/MongoChangeTemplate.java | 22 + .../validation/MongoOperationValidator.java | 382 ++++++++++ .../MongoTemplateValidationException.java | 60 ++ .../mongodb/validation/ValidationError.java | 59 ++ .../MongoOperationValidatorTest.java | 688 ++++++++++++++++++ 5 files changed, 1211 insertions(+) create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoTemplateValidationException.java create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/ValidationError.java create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/validation/MongoOperationValidatorTest.java 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 de8bdcaf9..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 @@ -24,6 +24,9 @@ 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; import java.util.ArrayList; @@ -97,6 +100,7 @@ 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)); } + validatePayload(applyPayload, changeId); executeOperationsWithAutoRollback(db, applyPayload, clientSession); } @@ -105,9 +109,27 @@ 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)); } + 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 executeOperationsWithAutoRollback(MongoDatabase db, MongoApplyPayload payload, ClientSession clientSession) { if (payload == null) { return; 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..e9d6e543b --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java @@ -0,0 +1,382 @@ +/* + * 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"
+ * }
+ * + *

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 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 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/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..643658e9a --- /dev/null +++ b/templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/validation/MongoOperationValidatorTest.java @@ -0,0 +1,688 @@ +/* + * 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("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", Arrays.asList(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()); + } + } +} From 3abf065b7d459e42391d03f76fef9c24e28b61a5 Mon Sep 17 00:00:00 2001 From: davidfrigolet Date: Sun, 11 Jan 2026 17:37:39 +0000 Subject: [PATCH 5/6] feat: mongodb sync template improvements and tests --- .../mongodb/mapper/IndexOptionsMapper.java | 9 +- .../mongodb/mapper/InsertOptionsMapper.java | 17 +-- .../template/mongodb/mapper/MapperUtil.java | 17 ++- .../mongodb/model/MongoOperation.java | 10 ++ .../mongodb/model/MongoOperationType.java | 2 + .../model/operator/InsertOperator.java | 20 ++- .../mongodb/model/operator/MongoOperator.java | 8 +- .../validation/MongoOperationValidator.java | 44 +++++++ .../MongoOperationValidatorTest.java | 124 +++++++++++++++++- 9 files changed, 220 insertions(+), 31 deletions(-) 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/model/MongoOperation.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/MongoOperation.java index 80460a487..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 @@ -109,6 +109,16 @@ public List getPipeline() { : 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); } 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 2180591e0..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 @@ -27,6 +27,7 @@ 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; @@ -36,6 +37,7 @@ public enum MongoOperationType { CREATE_COLLECTION("createCollection", CreateCollectionOperator::new), CREATE_INDEX("createIndex", CreateIndexOperator::new), INSERT("insert", InsertOperator::new), + UPDATE("update", UpdateOperator::new), DELETE("delete", DeleteOperator::new), DROP_COLLECTION("dropCollection", DropCollectionOperator::new), DROP_INDEX("dropIndex", DropIndexOperator::new), 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/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/validation/MongoOperationValidator.java b/templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/validation/MongoOperationValidator.java index e9d6e543b..e61a1b7bd 100644 --- 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 @@ -63,6 +63,21 @@ * 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
@@ -215,6 +230,8 @@ private static List validateByType(MongoOperationType type,
         switch (type) {
             case INSERT:
                 return validateInsert(op, entityId);
+            case UPDATE:
+                return validateUpdate(op, entityId);
             case DELETE:
                 return validateDelete(op, entityId);
             case CREATE_INDEX:
@@ -269,6 +286,33 @@ private static List validateInsert(MongoOperation op, String en
         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();
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
index 643658e9a..629120e1b 100644
--- 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
@@ -236,6 +236,128 @@ void insertValidTest() {
         }
     }
 
+    @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 {
@@ -495,7 +617,7 @@ void createViewMissingViewOnTest() {
             op.setType("createView");
             op.setCollection("testView");
             Map params = new HashMap<>();
-            params.put("pipeline", Arrays.asList(new HashMap<>()));
+            params.put("pipeline", Collections.singletonList(new HashMap<>()));
             op.setParameters(params);
 
             List errors = MongoOperationValidator.validate(op, ENTITY_ID);

From 4f8d46027a3343159e07481d0d86cf4530e658ff Mon Sep 17 00:00:00 2001
From: davidfrigolet 
Date: Sun, 11 Jan 2026 18:08:27 +0000
Subject: [PATCH 6/6] feat: mongodb sync template improvements and tests

---
 .../mongodb/mapper/UpdateOptionsMapper.java   |  63 +++
 .../model/operator/UpdateOperator.java        |  83 ++++
 .../mapper/CreateViewOptionsMapperTest.java   |  67 +++
 .../mapper/IndexOptionsMapperTest.java        | 322 +++++++++++++
 .../mapper/InsertOptionsMapperTest.java       | 166 +++++++
 .../mongodb/mapper/MapperUtilTest.java        | 452 ++++++++++++++++++
 .../RenameCollectionOptionsMapperTest.java    |  70 +++
 .../mapper/UpdateOptionsMapperTest.java       | 145 ++++++
 .../operations/UpdateOperatorTest.java        | 267 +++++++++++
 9 files changed, 1635 insertions(+)
 create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/mapper/UpdateOptionsMapper.java
 create mode 100644 templates/flamingock-mongodb-sync-template/src/main/java/io/flamingock/template/mongodb/model/operator/UpdateOperator.java
 create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/CreateViewOptionsMapperTest.java
 create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/IndexOptionsMapperTest.java
 create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/InsertOptionsMapperTest.java
 create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/MapperUtilTest.java
 create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/RenameCollectionOptionsMapperTest.java
 create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/mapper/UpdateOptionsMapperTest.java
 create mode 100644 templates/flamingock-mongodb-sync-template/src/test/java/io/flamingock/template/mongodb/operations/UpdateOperatorTest.java

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/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/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/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();
+    }
+}